feat: 다중구역이동 항적 분석 + STS 접촉 분석 프론트엔드 이관
- terra-draw 기반 지도 폴리곤/사각형/원 그리기 + 편집 (OL Draw 대체) - 구역 항적 분석: ANY/ALL/SEQUENTIAL 검색모드, 다중구역 시각화 - STS 선박쌍 접촉 분석: 접촉쌍 그룹핑, 위험도 indicator, ScatterplotLayer - Deck.gl 레이어: PathLayer + TripsLayer + IconLayer (커서 기반 O(1) 보간) - 공유 타임라인 컨트롤 (재생/배속/프로그레스바) - CSV 내보내기 (다중 방문 동적 컬럼, BOM+UTF-8) - ApiExplorer 5모드 통합 (positions/vessel/replay/area-search/sts) 신규 17파일 (features/area-search/), 수정 5파일 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
f41463d0f2
커밋
1cc25f9f3b
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@ -18,6 +18,8 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^2.15.3",
|
||||
"terra-draw": "^1.25.0",
|
||||
"terra-draw-maplibre-gl-adapter": "^1.3.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -5367,6 +5369,22 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/terra-draw": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/terra-draw/-/terra-draw-1.25.0.tgz",
|
||||
"integrity": "sha512-SQFdY6ObBqln01NFcYjrsVLPoAxHgl3fJszqU0pkDkxNIbV7FZUM102BcC2v9xXQ5Tr8hhnBELf1qG4GaXcALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/terra-draw-maplibre-gl-adapter": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/terra-draw-maplibre-gl-adapter/-/terra-draw-maplibre-gl-adapter-1.3.0.tgz",
|
||||
"integrity": "sha512-Q0Kds+IxLIcMPv3xYAJ3TDXp1Ml1uGk2xFN8Xx5tKpVdU9AhlTkpGxJ7i+Is1v6PC4t+O7MfyGP4Tl+R+qdYeQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"maplibre-gl": ">=4",
|
||||
"terra-draw": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/texture-compressor": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
|
||||
|
||||
@ -20,6 +20,8 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^2.15.3",
|
||||
"terra-draw": "^1.25.0",
|
||||
"terra-draw-maplibre-gl-adapter": "^1.3.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
136
frontend/src/features/area-search/components/AreaSearchPanel.tsx
Normal file
136
frontend/src/features/area-search/components/AreaSearchPanel.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useCallback } from 'react'
|
||||
import type maplibregl from 'maplibre-gl'
|
||||
import { useAreaSearchStore } from '../stores/areaSearchStore'
|
||||
import { useStsStore } from '../stores/stsStore'
|
||||
import { useAreaAnimationStore } from '../stores/animationStore'
|
||||
import { fetchAreaSearch } from '../services/areaSearchApi'
|
||||
import { fetchVesselContacts } from '../services/stsApi'
|
||||
import ZoneDrawPanel from './ZoneDrawPanel'
|
||||
import AreaSearchTab from './AreaSearchTab'
|
||||
import StsAnalysisTab from './StsAnalysisTab'
|
||||
import { useZoneDraw } from '../hooks/useZoneDraw'
|
||||
import { useZoneLayer } from '../hooks/useZoneLayer'
|
||||
|
||||
interface AreaSearchPanelProps {
|
||||
map: maplibregl.Map | null
|
||||
mode: 'area-search' | 'sts'
|
||||
}
|
||||
|
||||
export default function AreaSearchPanel({ map, mode }: AreaSearchPanelProps) {
|
||||
const maxZones = mode === 'sts' ? 1 : 3
|
||||
|
||||
// terra-draw + MapLibre zone layer 초기화
|
||||
useZoneDraw(map, maxZones)
|
||||
useZoneLayer(map)
|
||||
|
||||
const zones = useAreaSearchStore((s) => s.zones)
|
||||
const searchMode = useAreaSearchStore((s) => s.searchMode)
|
||||
const startTime = useAreaSearchStore((s) => s.startTime)
|
||||
const endTime = useAreaSearchStore((s) => s.endTime)
|
||||
const isLoadingArea = useAreaSearchStore((s) => s.isLoading)
|
||||
const isLoadingSts = useStsStore((s) => s.isLoading)
|
||||
const isLoading = isLoadingArea || isLoadingSts
|
||||
|
||||
const setStartTime = useAreaSearchStore((s) => s.setStartTime)
|
||||
const setEndTime = useAreaSearchStore((s) => s.setEndTime)
|
||||
|
||||
/** 구역 분석 조회 */
|
||||
const handleAreaSearch = useCallback(async () => {
|
||||
if (zones.length === 0) return
|
||||
const store = useAreaSearchStore.getState()
|
||||
store.setLoading(true)
|
||||
try {
|
||||
const polygons = zones.map((z) => ({ id: z.id, name: z.name, coordinates: z.coordinates }))
|
||||
const result = await fetchAreaSearch(startTime, endTime, searchMode, polygons)
|
||||
store.setResults(result.tracks, result.hitDetails, result.summary)
|
||||
|
||||
// 애니메이션 시간 범위 설정
|
||||
if (result.tracks.length > 0) {
|
||||
let min = Infinity, max = -Infinity
|
||||
for (const t of result.tracks) {
|
||||
if (t.timestampsMs.length > 0) {
|
||||
if (t.timestampsMs[0] < min) min = t.timestampsMs[0]
|
||||
if (t.timestampsMs[t.timestampsMs.length - 1] > max) max = t.timestampsMs[t.timestampsMs.length - 1]
|
||||
}
|
||||
}
|
||||
if (min < max) useAreaAnimationStore.getState().setTimeRange(min, max)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AreaSearch] 조회 실패:', err)
|
||||
store.setLoading(false)
|
||||
}
|
||||
}, [zones, startTime, endTime, searchMode])
|
||||
|
||||
/** STS 접촉 분석 조회 */
|
||||
const handleStsSearch = useCallback(async () => {
|
||||
if (zones.length === 0) return
|
||||
const stsState = useStsStore.getState()
|
||||
stsState.setLoading(true)
|
||||
try {
|
||||
const polygon = { id: zones[0].id, name: zones[0].name, coordinates: zones[0].coordinates }
|
||||
const result = await fetchVesselContacts(
|
||||
startTime, endTime, polygon,
|
||||
stsState.minContactDurationMinutes,
|
||||
stsState.maxContactDistanceMeters,
|
||||
)
|
||||
stsState.setResults(result)
|
||||
|
||||
// 애니메이션 시간 범위
|
||||
if (result.tracks.length > 0) {
|
||||
let min = Infinity, max = -Infinity
|
||||
for (const t of result.tracks) {
|
||||
if (t.timestampsMs.length > 0) {
|
||||
if (t.timestampsMs[0] < min) min = t.timestampsMs[0]
|
||||
if (t.timestampsMs[t.timestampsMs.length - 1] > max) max = t.timestampsMs[t.timestampsMs.length - 1]
|
||||
}
|
||||
}
|
||||
if (min < max) useAreaAnimationStore.getState().setTimeRange(min, max)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[STS] 조회 실패:', err)
|
||||
stsState.setLoading(false)
|
||||
}
|
||||
}, [zones, startTime, endTime])
|
||||
|
||||
const handleSearch = mode === 'sts' ? handleStsSearch : handleAreaSearch
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 시간 범위 */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 구역 그리기 */}
|
||||
<ZoneDrawPanel maxZones={maxZones} disabled={isLoading} />
|
||||
|
||||
{/* 모드별 탭 */}
|
||||
{mode === 'area-search' && <AreaSearchTab />}
|
||||
{mode === 'sts' && <StsAnalysisTab />}
|
||||
|
||||
{/* 조회 버튼 */}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={zones.length === 0 || isLoading}
|
||||
className="w-full rounded-md bg-primary px-3 py-2 text-sm font-medium text-white transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '조회 중...' : '조회'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
frontend/src/features/area-search/components/AreaSearchTab.tsx
Normal file
118
frontend/src/features/area-search/components/AreaSearchTab.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useCallback } 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'
|
||||
|
||||
export default function AreaSearchTab() {
|
||||
const searchMode = useAreaSearchStore((s) => s.searchMode)
|
||||
const setSearchMode = useAreaSearchStore((s) => s.setSearchMode)
|
||||
const tracks = useAreaSearchStore((s) => s.tracks)
|
||||
const hitDetails = useAreaSearchStore((s) => s.hitDetails)
|
||||
const zones = useAreaSearchStore((s) => s.zones)
|
||||
const summary = useAreaSearchStore((s) => s.summary)
|
||||
const disabledVesselIds = useAreaSearchStore((s) => s.disabledVesselIds)
|
||||
const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId)
|
||||
const toggleVesselEnabled = useAreaSearchStore((s) => s.toggleVesselEnabled)
|
||||
const setHighlightedVesselId = useAreaSearchStore((s) => s.setHighlightedVesselId)
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: SearchMode) => setSearchMode(mode),
|
||||
[setSearchMode],
|
||||
)
|
||||
|
||||
const handleExportCSV = useCallback(() => {
|
||||
if (tracks.length === 0) return
|
||||
exportAreaSearchCSV(tracks, hitDetails, zones)
|
||||
}, [tracks, hitDetails, zones])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 검색 모드 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">검색 모드</label>
|
||||
<div className="space-y-1">
|
||||
{SEARCH_MODE_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition ${
|
||||
searchMode === opt.value
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-foreground/70 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="searchMode"
|
||||
value={opt.value}
|
||||
checked={searchMode === opt.value}
|
||||
onChange={() => handleModeChange(opt.value)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-[10px] text-muted">{opt.desc}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결과 요약 */}
|
||||
{summary && (
|
||||
<div className="flex items-center justify-between rounded-md bg-primary/5 px-2 py-1.5 text-xs">
|
||||
<span className="font-medium text-primary">{summary.totalVessels}척 발견</span>
|
||||
{summary.processingTimeMs != null && (
|
||||
<span className="text-muted">{summary.processingTimeMs}ms</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 선박 리스트 */}
|
||||
{tracks.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted">결과 선박</span>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="text-[10px] text-primary hover:underline"
|
||||
>
|
||||
CSV 내보내기
|
||||
</button>
|
||||
</div>
|
||||
<ul className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||
{tracks.map((track) => {
|
||||
const isDisabled = disabledVesselIds.has(track.vesselId)
|
||||
const isHighlighted = highlightedVesselId === track.vesselId
|
||||
const hits = hitDetails[track.vesselId] || []
|
||||
return (
|
||||
<li
|
||||
key={track.vesselId}
|
||||
onMouseEnter={() => setHighlightedVesselId(track.vesselId)}
|
||||
onMouseLeave={() => setHighlightedVesselId(null)}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1 text-[11px] transition cursor-pointer ${
|
||||
isHighlighted ? 'bg-primary/10' : 'hover:bg-surface-hover/50'
|
||||
} ${isDisabled ? 'opacity-40' : ''}`}
|
||||
onClick={() => toggleVesselEnabled(track.vesselId)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isDisabled}
|
||||
onChange={() => toggleVesselEnabled(track.vesselId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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>
|
||||
{hits.length > 0 && (
|
||||
<span className="ml-auto text-[10px] text-primary">{hits.length}회</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import { useStsStore } from '../stores/stsStore'
|
||||
import { STS_LIMITS } from '../types/sts.types'
|
||||
import StsContactList from './StsContactList'
|
||||
|
||||
export default function StsAnalysisTab() {
|
||||
const minDuration = useStsStore((s) => s.minContactDurationMinutes)
|
||||
const maxDistance = useStsStore((s) => s.maxContactDistanceMeters)
|
||||
const setMinDuration = useStsStore((s) => s.setMinContactDuration)
|
||||
const setMaxDistance = useStsStore((s) => s.setMaxContactDistance)
|
||||
const summary = useStsStore((s) => s.summary)
|
||||
const queryCompleted = useStsStore((s) => s.queryCompleted)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 파라미터 슬라이더 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-muted">STS 탐지 조건</label>
|
||||
|
||||
{/* 최소 접촉 시간 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-foreground/70">최소 접촉 시간</span>
|
||||
<span className="font-mono font-medium text-primary">{minDuration}분</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={STS_LIMITS.DURATION_MIN}
|
||||
max={STS_LIMITS.DURATION_MAX}
|
||||
step={10}
|
||||
value={minDuration}
|
||||
onChange={(e) => setMinDuration(Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-muted">
|
||||
<span>{STS_LIMITS.DURATION_MIN}분</span>
|
||||
<span>{STS_LIMITS.DURATION_MAX}분</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최대 접촉 거리 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-foreground/70">최대 접촉 거리</span>
|
||||
<span className="font-mono font-medium text-primary">{maxDistance}m</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={STS_LIMITS.DISTANCE_MIN}
|
||||
max={STS_LIMITS.DISTANCE_MAX}
|
||||
step={50}
|
||||
value={maxDistance}
|
||||
onChange={(e) => setMaxDistance(Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-muted">
|
||||
<span>{STS_LIMITS.DISTANCE_MIN}m</span>
|
||||
<span>{STS_LIMITS.DISTANCE_MAX}m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결과 요약 */}
|
||||
{summary && (
|
||||
<div className="flex items-center gap-3 rounded-md bg-primary/5 px-2 py-1.5 text-xs">
|
||||
<span className="font-medium text-primary">{summary.totalContactPairs}쌍 발견</span>
|
||||
<span className="text-muted">{summary.totalVesselsInvolved}척 관련</span>
|
||||
{summary.processingTimeMs != null && (
|
||||
<span className="ml-auto text-muted">{summary.processingTimeMs}ms</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 접촉 쌍 리스트 */}
|
||||
{queryCompleted && <StsContactList />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
frontend/src/features/area-search/components/StsContactList.tsx
Normal file
151
frontend/src/features/area-search/components/StsContactList.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useRef, useEffect } 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'
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}분`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m > 0 ? `${h}시간 ${m}분` : `${h}시간`
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${Math.round(meters)}m`
|
||||
return `${(meters / 1000).toFixed(1)}km`
|
||||
}
|
||||
|
||||
function formatTimestamp(ms: number | null): string {
|
||||
if (!ms) return '-'
|
||||
const d = new Date(ms)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function GroupCard({ group, index }: { group: StsGroupedContact; index: number }) {
|
||||
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex)
|
||||
const disabledGroupIndices = useStsStore((s) => s.disabledGroupIndices)
|
||||
const expandedGroupIndex = useStsStore((s) => s.expandedGroupIndex)
|
||||
|
||||
const isHighlighted = highlightedGroupIndex === index
|
||||
const isDisabled = disabledGroupIndices.has(index)
|
||||
const isExpanded = expandedGroupIndex === index
|
||||
const riskColor = getContactRiskColor(group.indicators)
|
||||
|
||||
const activeIndicators = Object.entries(group.indicators || {}).filter(([, v]) => v)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`relative overflow-hidden rounded-lg border transition ${
|
||||
isHighlighted ? 'border-primary/40 bg-primary/5' : 'border-border'
|
||||
} ${isDisabled ? 'opacity-40' : ''}`}
|
||||
onMouseEnter={() => useStsStore.getState().setHighlightedGroupIndex(index)}
|
||||
onMouseLeave={() => useStsStore.getState().setHighlightedGroupIndex(null)}
|
||||
>
|
||||
{/* 위험도 색상 바 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-1"
|
||||
style={{ backgroundColor: `rgba(${riskColor.join(',')})` }}
|
||||
/>
|
||||
|
||||
<div className="space-y-1 pl-3 pr-2 py-2">
|
||||
{/* 선박 정보 */}
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted">{getShipKindLabel(group.vessel1.shipKindCode)}</span>
|
||||
<span className="font-medium">{group.vessel1.vesselName || group.vessel1.vesselId}</span>
|
||||
</div>
|
||||
<span className="text-muted/60">↔</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">{group.vessel2.vesselName || group.vessel2.vesselId}</span>
|
||||
<span className="text-muted">{getShipKindLabel(group.vessel2.shipKindCode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted">
|
||||
<span>{formatDuration(group.totalDurationMinutes)}</span>
|
||||
<span>평균 {formatDistance(group.avgDistanceMeters)}</span>
|
||||
{group.contacts.length > 1 && <span>{group.contacts.length}회</span>}
|
||||
</div>
|
||||
|
||||
{/* Indicator 뱃지 */}
|
||||
{activeIndicators.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activeIndicators.map(([key]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="rounded-sm bg-red-500/10 px-1.5 py-0.5 text-[9px] font-medium text-red-600"
|
||||
>
|
||||
{INDICATOR_LABELS[key] || key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 토글 + 체크 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isDisabled}
|
||||
onChange={() => useStsStore.getState().toggleGroupEnabled(index)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={() => useStsStore.getState().setExpandedGroupIndex(index)}
|
||||
className="text-[10px] text-primary hover:underline"
|
||||
>
|
||||
{isExpanded ? '접기 ▲' : '상세 ▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 확장 상세 */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-1 border-t border-border/50 pt-1 text-[10px] text-muted">
|
||||
<div>거리: 최소 {formatDistance(group.minDistanceMeters)} / 평균 {formatDistance(group.avgDistanceMeters)} / 최대 {formatDistance(group.maxDistanceMeters)}</div>
|
||||
<div>측정: {group.totalContactPointCount} 포인트</div>
|
||||
{group.contacts.length > 1 && (
|
||||
<div className="space-y-0.5">
|
||||
<span className="font-medium">접촉 이력 ({group.contacts.length}회)</span>
|
||||
{group.contacts.map((c, ci) => (
|
||||
<div key={ci} className="pl-2">
|
||||
#{ci + 1} {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)}
|
||||
{c.contactDurationMinutes != null && ` (${c.contactDurationMinutes}분)`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StsContactList() {
|
||||
const groupedContacts = useStsStore((s) => s.groupedContacts)
|
||||
const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
// 하이라이트된 카드로 스크롤
|
||||
useEffect(() => {
|
||||
if (highlightedGroupIndex === null || !listRef.current) return
|
||||
const items = listRef.current.children
|
||||
const el = items[highlightedGroupIndex] as HTMLElement | undefined
|
||||
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}, [highlightedGroupIndex])
|
||||
|
||||
if (groupedContacts.length === 0) {
|
||||
return <div className="py-2 text-center text-xs text-muted">접촉 쌍이 없습니다</div>
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import { useAreaAnimationStore, PLAYBACK_SPEEDS } from '../stores/animationStore'
|
||||
|
||||
export default function TimelineControl() {
|
||||
const isPlaying = useAreaAnimationStore((s) => s.isPlaying)
|
||||
const currentTime = useAreaAnimationStore((s) => s.currentTime)
|
||||
const startTime = useAreaAnimationStore((s) => s.startTime)
|
||||
const endTime = useAreaAnimationStore((s) => s.endTime)
|
||||
const playbackSpeed = useAreaAnimationStore((s) => s.playbackSpeed)
|
||||
const loop = useAreaAnimationStore((s) => s.loop)
|
||||
const play = useAreaAnimationStore((s) => s.play)
|
||||
const pause = useAreaAnimationStore((s) => s.pause)
|
||||
const stop = useAreaAnimationStore((s) => s.stop)
|
||||
const setPlaybackSpeed = useAreaAnimationStore((s) => s.setPlaybackSpeed)
|
||||
const setProgressByRatio = useAreaAnimationStore((s) => s.setProgressByRatio)
|
||||
const toggleLoop = useAreaAnimationStore((s) => s.toggleLoop)
|
||||
|
||||
const duration = endTime - startTime
|
||||
const progress = duration > 0 ? (currentTime - startTime) / duration : 0
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
const d = new Date(ms)
|
||||
return d.toLocaleString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
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="mb-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={Math.round(progress * 1000)}
|
||||
onChange={(e) => setProgressByRatio(Number(e.target.value) / 1000)}
|
||||
className="h-1.5 w-full cursor-pointer accent-primary"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-muted">
|
||||
<span>{formatTime(startTime)}</span>
|
||||
<span className="font-medium text-foreground">{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(endTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={stop}
|
||||
className="rounded px-2 py-1 text-xs text-muted hover:bg-surface-hover"
|
||||
title="정지"
|
||||
>
|
||||
■
|
||||
</button>
|
||||
<button
|
||||
onClick={isPlaying ? pause : play}
|
||||
className="rounded bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90"
|
||||
>
|
||||
{isPlaying ? '||' : '\u25B6'}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleLoop}
|
||||
className={`rounded px-2 py-1 text-xs transition ${
|
||||
loop ? 'text-primary' : 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
title="반복"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{PLAYBACK_SPEEDS.map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => setPlaybackSpeed(speed)}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] transition ${
|
||||
playbackSpeed === speed
|
||||
? 'bg-primary/10 font-bold text-primary'
|
||||
: 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
frontend/src/features/area-search/components/ZoneDrawPanel.tsx
Normal file
136
frontend/src/features/area-search/components/ZoneDrawPanel.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useAreaSearchStore } from '../stores/areaSearchStore'
|
||||
import { MAX_ZONES, ZONE_COLORS } from '../types/areaSearch.types'
|
||||
import type { ZoneDrawType } from '../types/areaSearch.types'
|
||||
|
||||
const DRAW_BUTTONS: { type: ZoneDrawType; label: string; icon: string }[] = [
|
||||
{ type: 'Polygon', label: '폴리곤', icon: '⬠' },
|
||||
{ type: 'Box', label: '사각형', icon: '▭' },
|
||||
{ type: 'Circle', label: '원', icon: '○' },
|
||||
]
|
||||
|
||||
interface ZoneDrawPanelProps {
|
||||
maxZones?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function ZoneDrawPanel({ maxZones = MAX_ZONES, disabled }: ZoneDrawPanelProps) {
|
||||
const zones = useAreaSearchStore((s) => s.zones)
|
||||
const activeDrawType = useAreaSearchStore((s) => s.activeDrawType)
|
||||
const setActiveDrawType = useAreaSearchStore((s) => s.setActiveDrawType)
|
||||
const removeZone = useAreaSearchStore((s) => s.removeZone)
|
||||
const reorderZones = useAreaSearchStore((s) => s.reorderZones)
|
||||
|
||||
const canAddZone = zones.length < maxZones
|
||||
|
||||
// 드래그 순서 변경
|
||||
const dragIndexRef = useRef<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
|
||||
const handleDrawClick = useCallback(
|
||||
(type: ZoneDrawType) => {
|
||||
if (!canAddZone || disabled) return
|
||||
setActiveDrawType(activeDrawType === type ? null : type)
|
||||
},
|
||||
[activeDrawType, canAddZone, disabled, setActiveDrawType],
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
dragIndexRef.current = index
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIndex(index)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIndex: number) => {
|
||||
e.preventDefault()
|
||||
const from = dragIndexRef.current
|
||||
if (from !== null && from !== toIndex) {
|
||||
reorderZones(from, toIndex)
|
||||
}
|
||||
dragIndexRef.current = null
|
||||
setDragOverIndex(null)
|
||||
},
|
||||
[reorderZones],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted">구역 설정</span>
|
||||
<span className="text-[10px] text-muted">{zones.length}/{maxZones}</span>
|
||||
</div>
|
||||
|
||||
{/* 그리기 버튼 */}
|
||||
<div className="flex gap-1">
|
||||
{DRAW_BUTTONS.map((btn) => (
|
||||
<button
|
||||
key={btn.type}
|
||||
onClick={() => handleDrawClick(btn.type)}
|
||||
disabled={!canAddZone || disabled}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 text-xs transition ${
|
||||
activeDrawType === btn.type
|
||||
? 'bg-primary/20 font-medium text-primary ring-1 ring-primary/40'
|
||||
: 'bg-surface-hover text-foreground/70 hover:bg-surface-hover/80'
|
||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||
>
|
||||
<span className="mr-1">{btn.icon}</span>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeDrawType && (
|
||||
<p className="text-[10px] text-muted">
|
||||
{activeDrawType === 'Polygon' && '지도를 클릭하여 꼭짓점을 추가하세요. 더블클릭으로 완료.'}
|
||||
{activeDrawType === 'Box' && '드래그하여 사각형을 그리세요.'}
|
||||
{activeDrawType === 'Circle' && '드래그하여 원을 그리세요.'}
|
||||
<span className="ml-1 text-muted/60">(ESC: 취소)</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 구역 목록 */}
|
||||
{zones.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{zones.map((zone, index) => {
|
||||
const color = ZONE_COLORS[zone.colorIndex % ZONE_COLORS.length]
|
||||
return (
|
||||
<li
|
||||
key={zone.id}
|
||||
draggable={zones.length > 1}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={() => { dragIndexRef.current = null; setDragOverIndex(null) }}
|
||||
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition ${
|
||||
dragOverIndex === index ? 'ring-1 ring-primary/40' : ''
|
||||
} bg-surface-hover/50 hover:bg-surface-hover`}
|
||||
>
|
||||
{zones.length > 1 && (
|
||||
<span className="cursor-grab text-muted/50 active:cursor-grabbing">☰</span>
|
||||
)}
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: color.label }}
|
||||
/>
|
||||
<span className="font-medium">구역 {zone.name}</span>
|
||||
<span className="text-[10px] text-muted">{zone.type === 'Box' ? '사각형' : zone.type === 'Circle' ? '원' : '폴리곤'}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeZone(zone.id) }}
|
||||
className="ml-auto text-muted hover:text-red-500 transition"
|
||||
title="삭제"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
frontend/src/features/area-search/hooks/useAreaSearchLayer.ts
Normal file
178
frontend/src/features/area-search/hooks/useAreaSearchLayer.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import type { Layer } from '@deck.gl/core'
|
||||
import { TripsLayer } from '@deck.gl/geo-layers'
|
||||
import { useAreaSearchStore } from '../stores/areaSearchStore'
|
||||
import { useAreaAnimationStore } from '../stores/animationStore'
|
||||
import { createTrackPathLayer, createVesselIconLayer } from '../../vessel-map'
|
||||
import { getTrackColor, getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors'
|
||||
import type { TrackPathData, VesselIconData } from '../../vessel-map'
|
||||
|
||||
const TRAIL_LENGTH_MS = 3_600_000 // 1시간 트레일
|
||||
const RENDER_INTERVAL_MS = 100 // ~10fps 스로틀
|
||||
|
||||
interface TripsData {
|
||||
vesselId: string
|
||||
shipKindCode: string
|
||||
path: [number, number][]
|
||||
timestamps: number[]
|
||||
}
|
||||
|
||||
interface UseAreaSearchLayerProps {
|
||||
zoom: number
|
||||
onLayersUpdate: (layers: Layer[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 구역분석 Deck.gl 레이어: PathLayer(정적 항적) + TripsLayer(궤적) + IconLayer(보간 선박)
|
||||
* zustand subscribe로 React 렌더 바이패스
|
||||
*/
|
||||
export function useAreaSearchLayer({ zoom, onLayersUpdate }: UseAreaSearchLayerProps) {
|
||||
const queryCompleted = useAreaSearchStore((s) => s.queryCompleted)
|
||||
const tripsDataRef = useRef<TripsData[]>([])
|
||||
const pathDataRef = useRef<TrackPathData[]>([])
|
||||
const startTimeRef = useRef(0)
|
||||
const lastRenderTimeRef = useRef(0)
|
||||
|
||||
// 정적 데이터 구축 (결과 변경 시)
|
||||
const buildStaticData = useCallback(() => {
|
||||
const { tracks, disabledVesselIds } = useAreaSearchStore.getState()
|
||||
const enabledTracks = tracks.filter((t) => !disabledVesselIds.has(t.vesselId))
|
||||
|
||||
if (enabledTracks.length === 0) {
|
||||
tripsDataRef.current = []
|
||||
pathDataRef.current = []
|
||||
startTimeRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
// 시간 기준점
|
||||
let min = Infinity
|
||||
for (const t of enabledTracks) {
|
||||
if (t.timestampsMs.length > 0 && t.timestampsMs[0] < min) min = t.timestampsMs[0]
|
||||
}
|
||||
startTimeRef.current = min
|
||||
|
||||
// PathLayer 데이터
|
||||
pathDataRef.current = enabledTracks.map((t) => ({
|
||||
vesselId: t.vesselId,
|
||||
path: t.geometry,
|
||||
color: getTrackColor(t.shipKindCode),
|
||||
shipKindCode: t.shipKindCode,
|
||||
}))
|
||||
|
||||
// TripsLayer 데이터
|
||||
tripsDataRef.current = enabledTracks.map((t) => ({
|
||||
vesselId: t.vesselId,
|
||||
shipKindCode: t.shipKindCode || '000027',
|
||||
path: t.geometry,
|
||||
timestamps: t.timestampsMs.map((ts) => ts - min),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// 결과/필터 변경 시 정적 데이터 재빌드
|
||||
useEffect(() => {
|
||||
if (!queryCompleted) return
|
||||
|
||||
buildStaticData()
|
||||
|
||||
// 필터 변경 구독
|
||||
const unsub = useAreaSearchStore.subscribe(
|
||||
(state, prev) => {
|
||||
if (state.disabledVesselIds !== prev.disabledVesselIds || state.highlightedVesselId !== prev.highlightedVesselId) {
|
||||
buildStaticData()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return unsub
|
||||
}, [queryCompleted, buildStaticData])
|
||||
|
||||
// 렌더링 루프
|
||||
useEffect(() => {
|
||||
if (!queryCompleted || tripsDataRef.current.length === 0) {
|
||||
onLayersUpdate([])
|
||||
return
|
||||
}
|
||||
|
||||
const renderFrame = () => {
|
||||
const { currentTime } = useAreaAnimationStore.getState()
|
||||
const relativeTime = currentTime - startTimeRef.current
|
||||
const highlightedId = useAreaSearchStore.getState().highlightedVesselId
|
||||
|
||||
// 보간 위치
|
||||
const positions = useAreaSearchStore.getState().getCurrentPositions(currentTime)
|
||||
const iconData: VesselIconData[] = positions.map((p) => {
|
||||
const displaySog = p.speed > 0 ? p.speed : 2
|
||||
return {
|
||||
mmsi: p.vesselId,
|
||||
position: [p.lon, p.lat],
|
||||
angle: p.heading,
|
||||
icon: getIconKey(p.shipKindCode, displaySog),
|
||||
size: getIconSize(zoom, p.shipKindCode, displaySog),
|
||||
shipNm: p.shipName,
|
||||
shipKindCode: p.shipKindCode,
|
||||
sog: p.speed,
|
||||
}
|
||||
})
|
||||
|
||||
// 하이라이트 적용
|
||||
const pathData = highlightedId
|
||||
? pathDataRef.current.map((d) => ({
|
||||
...d,
|
||||
color: d.vesselId === highlightedId
|
||||
? d.color
|
||||
: [d.color[0], d.color[1], d.color[2], 60] as [number, number, number, number],
|
||||
}))
|
||||
: pathDataRef.current
|
||||
|
||||
const layers: Layer[] = [
|
||||
createTrackPathLayer({
|
||||
id: 'area-path',
|
||||
data: pathData,
|
||||
widthMinPixels: 2,
|
||||
}),
|
||||
new TripsLayer<TripsData>({
|
||||
id: 'area-trails',
|
||||
data: tripsDataRef.current,
|
||||
getPath: (d) => d.path,
|
||||
getTimestamps: (d) => d.timestamps,
|
||||
getColor: [120, 120, 120, 180],
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 3,
|
||||
jointRounded: true,
|
||||
capRounded: true,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: relativeTime,
|
||||
}),
|
||||
createVesselIconLayer({
|
||||
id: 'area-ships',
|
||||
data: iconData,
|
||||
pickable: false,
|
||||
}),
|
||||
]
|
||||
|
||||
onLayersUpdate(layers)
|
||||
}
|
||||
|
||||
renderFrame()
|
||||
|
||||
let prevTime = useAreaAnimationStore.getState().currentTime
|
||||
const unsub = useAreaAnimationStore.subscribe((state) => {
|
||||
if (state.currentTime === prevTime) return
|
||||
prevTime = state.currentTime
|
||||
|
||||
if (!state.isPlaying) {
|
||||
renderFrame()
|
||||
return
|
||||
}
|
||||
const now = performance.now()
|
||||
if (now - lastRenderTimeRef.current >= RENDER_INTERVAL_MS) {
|
||||
lastRenderTimeRef.current = now
|
||||
renderFrame()
|
||||
}
|
||||
})
|
||||
|
||||
return unsub
|
||||
}, [queryCompleted, zoom, onLayersUpdate])
|
||||
}
|
||||
224
frontend/src/features/area-search/hooks/useStsLayer.ts
Normal file
224
frontend/src/features/area-search/hooks/useStsLayer.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import type { Layer } from '@deck.gl/core'
|
||||
import { ScatterplotLayer } from '@deck.gl/layers'
|
||||
import { TripsLayer } from '@deck.gl/geo-layers'
|
||||
import { useStsStore } from '../stores/stsStore'
|
||||
import { useAreaAnimationStore } from '../stores/animationStore'
|
||||
import { getContactRiskColor } from '../types/sts.types'
|
||||
import { createTrackPathLayer, createVesselIconLayer } from '../../vessel-map'
|
||||
import { getTrackColor, getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors'
|
||||
import type { TrackPathData, VesselIconData } from '../../vessel-map'
|
||||
|
||||
const TRAIL_LENGTH_MS = 3_600_000
|
||||
const RENDER_INTERVAL_MS = 100
|
||||
|
||||
interface TripsData {
|
||||
vesselId: string
|
||||
shipKindCode: string
|
||||
path: [number, number][]
|
||||
timestamps: number[]
|
||||
}
|
||||
|
||||
interface ContactPointData {
|
||||
position: [number, number]
|
||||
color: [number, number, number, number]
|
||||
radius: number
|
||||
groupIndex: number
|
||||
}
|
||||
|
||||
interface UseStsLayerProps {
|
||||
zoom: number
|
||||
onLayersUpdate: (layers: Layer[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* STS 분석 Deck.gl 레이어:
|
||||
* PathLayer(항적) + ScatterplotLayer(접촉 포인트) + TripsLayer(궤적) + IconLayer(보간 선박)
|
||||
*/
|
||||
export function useStsLayer({ zoom, onLayersUpdate }: UseStsLayerProps) {
|
||||
const queryCompleted = useStsStore((s) => s.queryCompleted)
|
||||
const tripsDataRef = useRef<TripsData[]>([])
|
||||
const pathDataRef = useRef<TrackPathData[]>([])
|
||||
const contactPointsRef = useRef<ContactPointData[]>([])
|
||||
const startTimeRef = useRef(0)
|
||||
const lastRenderTimeRef = useRef(0)
|
||||
|
||||
const buildStaticData = useCallback(() => {
|
||||
const { tracks, groupedContacts, disabledGroupIndices } = useStsStore.getState()
|
||||
const disabledIds = useStsStore.getState().getDisabledVesselIds()
|
||||
const enabledTracks = tracks.filter((t) => !disabledIds.has(t.vesselId))
|
||||
|
||||
if (enabledTracks.length === 0) {
|
||||
tripsDataRef.current = []
|
||||
pathDataRef.current = []
|
||||
contactPointsRef.current = []
|
||||
startTimeRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
let min = Infinity
|
||||
for (const t of enabledTracks) {
|
||||
if (t.timestampsMs.length > 0 && t.timestampsMs[0] < min) min = t.timestampsMs[0]
|
||||
}
|
||||
startTimeRef.current = min
|
||||
|
||||
pathDataRef.current = enabledTracks.map((t) => ({
|
||||
vesselId: t.vesselId,
|
||||
path: t.geometry,
|
||||
color: getTrackColor(t.shipKindCode),
|
||||
shipKindCode: t.shipKindCode,
|
||||
}))
|
||||
|
||||
tripsDataRef.current = enabledTracks.map((t) => ({
|
||||
vesselId: t.vesselId,
|
||||
shipKindCode: t.shipKindCode || '000027',
|
||||
path: t.geometry,
|
||||
timestamps: t.timestampsMs.map((ts) => ts - min),
|
||||
}))
|
||||
|
||||
// 접촉 포인트 (그룹별 중심점 + 위험도 색상)
|
||||
const points: ContactPointData[] = []
|
||||
groupedContacts.forEach((group, idx) => {
|
||||
if (disabledGroupIndices.has(idx)) return
|
||||
if (group.contactCenterPoint) {
|
||||
points.push({
|
||||
position: group.contactCenterPoint,
|
||||
color: getContactRiskColor(group.indicators),
|
||||
radius: Math.max(50, Math.min(500, group.avgDistanceMeters / 2)),
|
||||
groupIndex: idx,
|
||||
})
|
||||
}
|
||||
// 개별 접촉 포인트도 추가
|
||||
for (const c of group.contacts) {
|
||||
if (c.contactCenterPoint && c.contactCenterPoint !== group.contactCenterPoint) {
|
||||
points.push({
|
||||
position: c.contactCenterPoint,
|
||||
color: getContactRiskColor(c.indicators),
|
||||
radius: Math.max(30, Math.min(300, (c.avgDistanceMeters || 200) / 3)),
|
||||
groupIndex: idx,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
contactPointsRef.current = points
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryCompleted) return
|
||||
|
||||
buildStaticData()
|
||||
|
||||
const unsub = useStsStore.subscribe(
|
||||
(state, prev) => {
|
||||
if (state.disabledGroupIndices !== prev.disabledGroupIndices || state.highlightedGroupIndex !== prev.highlightedGroupIndex) {
|
||||
buildStaticData()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return unsub
|
||||
}, [queryCompleted, buildStaticData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryCompleted || tripsDataRef.current.length === 0) {
|
||||
onLayersUpdate([])
|
||||
return
|
||||
}
|
||||
|
||||
const renderFrame = () => {
|
||||
const { currentTime } = useAreaAnimationStore.getState()
|
||||
const relativeTime = currentTime - startTimeRef.current
|
||||
const highlightedIdx = useStsStore.getState().highlightedGroupIndex
|
||||
|
||||
const positions = useStsStore.getState().getCurrentPositions(currentTime)
|
||||
const iconData: VesselIconData[] = positions.map((p) => {
|
||||
const displaySog = p.speed > 0 ? p.speed : 2
|
||||
return {
|
||||
mmsi: p.vesselId,
|
||||
position: [p.lon, p.lat],
|
||||
angle: p.heading,
|
||||
icon: getIconKey(p.shipKindCode, displaySog),
|
||||
size: getIconSize(zoom, p.shipKindCode, displaySog),
|
||||
shipNm: p.shipName,
|
||||
shipKindCode: p.shipKindCode,
|
||||
sog: p.speed,
|
||||
}
|
||||
})
|
||||
|
||||
// 하이라이트 적용
|
||||
const contactPoints = highlightedIdx !== null
|
||||
? contactPointsRef.current.map((d) => ({
|
||||
...d,
|
||||
color: d.groupIndex === highlightedIdx
|
||||
? d.color
|
||||
: [d.color[0], d.color[1], d.color[2], 60] as [number, number, number, number],
|
||||
}))
|
||||
: contactPointsRef.current
|
||||
|
||||
const layers: Layer[] = [
|
||||
createTrackPathLayer({
|
||||
id: 'sts-path',
|
||||
data: pathDataRef.current,
|
||||
widthMinPixels: 2,
|
||||
}),
|
||||
new ScatterplotLayer<ContactPointData>({
|
||||
id: 'sts-contact-points',
|
||||
data: contactPoints,
|
||||
getPosition: (d) => d.position,
|
||||
getFillColor: (d) => d.color,
|
||||
getRadius: (d) => d.radius,
|
||||
radiusUnits: 'meters' as const,
|
||||
radiusMinPixels: 6,
|
||||
radiusMaxPixels: 40,
|
||||
opacity: 0.7,
|
||||
stroked: true,
|
||||
getLineColor: [255, 255, 255, 180],
|
||||
getLineWidth: 1,
|
||||
lineWidthUnits: 'pixels' as const,
|
||||
pickable: false,
|
||||
}),
|
||||
new TripsLayer<TripsData>({
|
||||
id: 'sts-trails',
|
||||
data: tripsDataRef.current,
|
||||
getPath: (d) => d.path,
|
||||
getTimestamps: (d) => d.timestamps,
|
||||
getColor: [120, 120, 120, 180],
|
||||
widthMinPixels: 2,
|
||||
widthMaxPixels: 3,
|
||||
jointRounded: true,
|
||||
capRounded: true,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: relativeTime,
|
||||
}),
|
||||
createVesselIconLayer({
|
||||
id: 'sts-ships',
|
||||
data: iconData,
|
||||
pickable: false,
|
||||
}),
|
||||
]
|
||||
|
||||
onLayersUpdate(layers)
|
||||
}
|
||||
|
||||
renderFrame()
|
||||
|
||||
let prevTime = useAreaAnimationStore.getState().currentTime
|
||||
const unsub = useAreaAnimationStore.subscribe((state) => {
|
||||
if (state.currentTime === prevTime) return
|
||||
prevTime = state.currentTime
|
||||
|
||||
if (!state.isPlaying) {
|
||||
renderFrame()
|
||||
return
|
||||
}
|
||||
const now = performance.now()
|
||||
if (now - lastRenderTimeRef.current >= RENDER_INTERVAL_MS) {
|
||||
lastRenderTimeRef.current = now
|
||||
renderFrame()
|
||||
}
|
||||
})
|
||||
|
||||
return unsub
|
||||
}, [queryCompleted, zoom, onLayersUpdate])
|
||||
}
|
||||
205
frontend/src/features/area-search/hooks/useZoneDraw.ts
Normal file
205
frontend/src/features/area-search/hooks/useZoneDraw.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
TerraDraw,
|
||||
TerraDrawPolygonMode,
|
||||
TerraDrawRectangleMode,
|
||||
TerraDrawCircleMode,
|
||||
TerraDrawSelectMode,
|
||||
} from 'terra-draw'
|
||||
import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter'
|
||||
import type maplibregl from 'maplibre-gl'
|
||||
import { useAreaSearchStore } from '../stores/areaSearchStore'
|
||||
import { MAX_ZONES, ZONE_COLORS } from '../types/areaSearch.types'
|
||||
import type { ZoneDrawType } from '../types/areaSearch.types'
|
||||
|
||||
/** terra-draw 모드 이름 → ZoneDrawType 변환 */
|
||||
function toZoneType(mode: string): ZoneDrawType {
|
||||
if (mode === 'rectangle') return 'Box'
|
||||
if (mode === 'circle') return 'Circle'
|
||||
return 'Polygon'
|
||||
}
|
||||
|
||||
/** ZoneDrawType → terra-draw 모드 이름 변환 */
|
||||
function toDrawMode(type: ZoneDrawType): string {
|
||||
if (type === 'Box') return 'rectangle'
|
||||
if (type === 'Circle') return 'circle'
|
||||
return 'polygon'
|
||||
}
|
||||
|
||||
/**
|
||||
* terra-draw 기반 폴리곤/사각형/원 그리기 + 편집 훅
|
||||
*
|
||||
* Dark의 useZoneDraw.ts (OL Draw) + useZoneEdit.ts (OL Modify/Translate)를
|
||||
* terra-draw 하나로 통합. 좌표는 WGS84(4326) 네이티브.
|
||||
*/
|
||||
export function useZoneDraw(map: maplibregl.Map | null, maxZones = MAX_ZONES) {
|
||||
const drawRef = useRef<TerraDraw | null>(null)
|
||||
const featureZoneMap = useRef<Map<string, string>>(new Map()) // terraDrawFeatureId → zoneId
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
|
||||
const toHex = (r: number, g: number, b: number) =>
|
||||
`#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}` as `#${string}`
|
||||
|
||||
const colorForIndex = (idx: number) => {
|
||||
const c = ZONE_COLORS[idx % ZONE_COLORS.length]
|
||||
return toHex(c.stroke[0], c.stroke[1], c.stroke[2])
|
||||
}
|
||||
|
||||
const fillForIndex = (idx: number) => {
|
||||
const c = ZONE_COLORS[idx % ZONE_COLORS.length]
|
||||
return toHex(c.fill[0], c.fill[1], c.fill[2])
|
||||
}
|
||||
|
||||
const draw = new TerraDraw({
|
||||
adapter: new TerraDrawMapLibreGLAdapter({ map }),
|
||||
modes: [
|
||||
new TerraDrawPolygonMode({
|
||||
styles: {
|
||||
fillColor: fillForIndex(0),
|
||||
outlineColor: colorForIndex(0),
|
||||
outlineWidth: 2,
|
||||
closingPointColor: colorForIndex(0),
|
||||
closingPointWidth: 6,
|
||||
closingPointOutlineColor: '#ffffff',
|
||||
closingPointOutlineWidth: 2,
|
||||
},
|
||||
}),
|
||||
new TerraDrawRectangleMode({
|
||||
styles: {
|
||||
fillColor: fillForIndex(0),
|
||||
outlineColor: colorForIndex(0),
|
||||
outlineWidth: 2,
|
||||
},
|
||||
}),
|
||||
new TerraDrawCircleMode({
|
||||
styles: {
|
||||
fillColor: fillForIndex(0),
|
||||
outlineColor: colorForIndex(0),
|
||||
outlineWidth: 2,
|
||||
},
|
||||
}),
|
||||
new TerraDrawSelectMode({
|
||||
flags: {
|
||||
polygon: { feature: { draggable: true, coordinates: { midpoints: true, draggable: true, deletable: true } } },
|
||||
rectangle: { feature: { draggable: true, coordinates: { midpoints: false, draggable: true, deletable: false } } },
|
||||
circle: { feature: { draggable: true, coordinates: { midpoints: false, draggable: false, deletable: false } } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
draw.start()
|
||||
draw.setMode('static')
|
||||
drawRef.current = draw
|
||||
|
||||
// 그리기 완료 이벤트
|
||||
draw.on('finish', (id: string | number) => {
|
||||
const fid = String(id)
|
||||
const store = useAreaSearchStore.getState()
|
||||
if (store.zones.length >= maxZones) {
|
||||
draw.removeFeatures([fid])
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = draw.getSnapshot()
|
||||
const feature = snapshot.find((f) => String(f.id) === fid)
|
||||
if (!feature || feature.geometry.type !== 'Polygon') {
|
||||
draw.removeFeatures([fid])
|
||||
return
|
||||
}
|
||||
|
||||
const coordinates = (feature.geometry.coordinates[0] as number[][]).map(([lon, lat]) => [lon, lat])
|
||||
const mode = feature.properties?.mode as string || 'polygon'
|
||||
|
||||
store.addZone({
|
||||
type: toZoneType(mode),
|
||||
coordinates,
|
||||
terraDrawFeatureId: fid,
|
||||
})
|
||||
|
||||
// featureId → zoneId 매핑 업데이트
|
||||
const zones = useAreaSearchStore.getState().zones
|
||||
const lastZone = zones[zones.length - 1]
|
||||
if (lastZone) {
|
||||
featureZoneMap.current.set(fid, lastZone.id)
|
||||
}
|
||||
|
||||
draw.setMode('select')
|
||||
})
|
||||
|
||||
// 편집 완료 이벤트 (Select 모드에서 드래그/꼭짓점 수정 후)
|
||||
draw.on('change', (ids: (string | number)[]) => {
|
||||
const snapshot = draw.getSnapshot()
|
||||
for (const id of ids) {
|
||||
const fid = String(id)
|
||||
const zoneId = featureZoneMap.current.get(fid)
|
||||
if (!zoneId) continue
|
||||
const feature = snapshot.find((f) => String(f.id) === fid)
|
||||
if (feature?.geometry.type === 'Polygon') {
|
||||
const coords = (feature.geometry.coordinates[0] as number[][]).map(([lon, lat]) => [lon, lat])
|
||||
useAreaSearchStore.getState().updateZoneGeometry(zoneId, coords)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ESC 키: 그리기 취소 → select 모드 복귀
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
useAreaSearchStore.getState().setActiveDrawType(null)
|
||||
draw.setMode('select')
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
draw.stop()
|
||||
drawRef.current = null
|
||||
featureZoneMap.current.clear()
|
||||
}
|
||||
}, [map, maxZones])
|
||||
|
||||
// activeDrawType 변경 시 terra-draw 모드 전환
|
||||
useEffect(() => {
|
||||
let prevType = useAreaSearchStore.getState().activeDrawType
|
||||
const unsub = useAreaSearchStore.subscribe((state) => {
|
||||
if (state.activeDrawType === prevType) return
|
||||
prevType = state.activeDrawType
|
||||
const draw = drawRef.current
|
||||
if (!draw) return
|
||||
if (state.activeDrawType) {
|
||||
draw.setMode(toDrawMode(state.activeDrawType))
|
||||
} else {
|
||||
draw.setMode('select')
|
||||
}
|
||||
})
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
// 구역 삭제 시 terra-draw에서도 feature 제거
|
||||
useEffect(() => {
|
||||
let prevZones = useAreaSearchStore.getState().zones
|
||||
const unsub = useAreaSearchStore.subscribe((state) => {
|
||||
if (state.zones === prevZones) return
|
||||
prevZones = state.zones
|
||||
const draw = drawRef.current
|
||||
if (!draw) return
|
||||
|
||||
// 현재 zones에 없는 featureId 삭제
|
||||
const activeFeatureIds = new Set(state.zones.map((z) => z.terraDrawFeatureId).filter(Boolean))
|
||||
const toRemove: string[] = []
|
||||
for (const [featureId] of featureZoneMap.current) {
|
||||
if (!activeFeatureIds.has(featureId)) {
|
||||
toRemove.push(featureId)
|
||||
}
|
||||
}
|
||||
if (toRemove.length > 0) {
|
||||
try { draw.removeFeatures(toRemove) } catch { /* feature already removed */ }
|
||||
toRemove.forEach((id) => featureZoneMap.current.delete(id))
|
||||
}
|
||||
})
|
||||
return unsub
|
||||
}, [])
|
||||
}
|
||||
130
frontend/src/features/area-search/hooks/useZoneLayer.ts
Normal file
130
frontend/src/features/area-search/hooks/useZoneLayer.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type maplibregl from 'maplibre-gl'
|
||||
import { useAreaSearchStore } from '../stores/areaSearchStore'
|
||||
import { ZONE_COLORS } from '../types/areaSearch.types'
|
||||
|
||||
const SOURCE_ID = 'zone-polygons'
|
||||
const FILL_LAYER = 'zone-fill'
|
||||
const LINE_LAYER = 'zone-line'
|
||||
const LABEL_LAYER = 'zone-label'
|
||||
|
||||
function buildGeoJSON(zones: { coordinates: number[][]; colorIndex: number; name: string }[]) {
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: zones.map((z, i) => {
|
||||
const c = ZONE_COLORS[z.colorIndex % ZONE_COLORS.length]
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
id: i,
|
||||
properties: {
|
||||
name: z.name,
|
||||
fillColor: `rgba(${c.fill[0]},${c.fill[1]},${c.fill[2]},${c.fill[3]})`,
|
||||
strokeColor: `rgba(${c.stroke[0]},${c.stroke[1]},${c.stroke[2]},${c.stroke[3]})`,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [z.coordinates],
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MapLibre GeoJSON source/layer로 구역 시각화
|
||||
* terra-draw 비활성 상태에서 구역 표시 용도
|
||||
*/
|
||||
export function useZoneLayer(map: maplibregl.Map | null) {
|
||||
const addedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
|
||||
const addLayers = () => {
|
||||
if (addedRef.current) return
|
||||
if (map.getSource(SOURCE_ID)) return
|
||||
|
||||
map.addSource(SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: buildGeoJSON([]),
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: FILL_LAYER,
|
||||
type: 'fill',
|
||||
source: SOURCE_ID,
|
||||
paint: {
|
||||
'fill-color': ['get', 'fillColor'],
|
||||
'fill-opacity': 1,
|
||||
},
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: LINE_LAYER,
|
||||
type: 'line',
|
||||
source: SOURCE_ID,
|
||||
paint: {
|
||||
'line-color': ['get', 'strokeColor'],
|
||||
'line-width': 2,
|
||||
},
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: LABEL_LAYER,
|
||||
type: 'symbol',
|
||||
source: SOURCE_ID,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 14,
|
||||
'text-font': ['Open Sans Bold'],
|
||||
'text-anchor': 'center',
|
||||
},
|
||||
paint: {
|
||||
'text-color': ['get', 'strokeColor'],
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
},
|
||||
})
|
||||
|
||||
addedRef.current = true
|
||||
}
|
||||
|
||||
// 맵 스타일 로드 후 레이어 추가
|
||||
if (map.isStyleLoaded()) {
|
||||
addLayers()
|
||||
} else {
|
||||
map.once('styledata', addLayers)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!addedRef.current) return
|
||||
try {
|
||||
if (map.getLayer(LABEL_LAYER)) map.removeLayer(LABEL_LAYER)
|
||||
if (map.getLayer(LINE_LAYER)) map.removeLayer(LINE_LAYER)
|
||||
if (map.getLayer(FILL_LAYER)) map.removeLayer(FILL_LAYER)
|
||||
if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID)
|
||||
} catch { /* map already disposed */ }
|
||||
addedRef.current = false
|
||||
}
|
||||
}, [map])
|
||||
|
||||
// zones 변경 시 GeoJSON source 갱신
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
|
||||
let prevZones = useAreaSearchStore.getState().zones
|
||||
const unsub = useAreaSearchStore.subscribe((state) => {
|
||||
if (state.zones === prevZones) return
|
||||
prevZones = state.zones
|
||||
|
||||
if (!addedRef.current) return
|
||||
const source = map.getSource(SOURCE_ID) as maplibregl.GeoJSONSource | undefined
|
||||
if (!source) return
|
||||
|
||||
const data = buildGeoJSON(state.zones)
|
||||
source.setData(data as GeoJSON.FeatureCollection)
|
||||
})
|
||||
|
||||
return unsub
|
||||
}, [map])
|
||||
}
|
||||
28
frontend/src/features/area-search/index.ts
Normal file
28
frontend/src/features/area-search/index.ts
Normal file
@ -0,0 +1,28 @@
|
||||
// Types
|
||||
export type { Zone, ZoneDrawType, SearchMode, HitDetail, AreaSearchSummary } from './types/areaSearch.types'
|
||||
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'
|
||||
|
||||
// Stores
|
||||
export { useAreaSearchStore } from './stores/areaSearchStore'
|
||||
export { useStsStore } from './stores/stsStore'
|
||||
export { useAreaAnimationStore } from './stores/animationStore'
|
||||
|
||||
// Services
|
||||
export { fetchAreaSearch } from './services/areaSearchApi'
|
||||
export { fetchVesselContacts } from './services/stsApi'
|
||||
|
||||
// Hooks
|
||||
export { useZoneDraw } from './hooks/useZoneDraw'
|
||||
export { useZoneLayer } from './hooks/useZoneLayer'
|
||||
export { useAreaSearchLayer } from './hooks/useAreaSearchLayer'
|
||||
export { useStsLayer } from './hooks/useStsLayer'
|
||||
|
||||
// Components
|
||||
export { default as AreaSearchPanel } from './components/AreaSearchPanel'
|
||||
export { default as TimelineControl } from './components/TimelineControl'
|
||||
|
||||
// Utils
|
||||
export { exportAreaSearchCSV } from './utils/csvExport'
|
||||
125
frontend/src/features/area-search/services/areaSearchApi.ts
Normal file
125
frontend/src/features/area-search/services/areaSearchApi.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { postJson } from '../../../api/httpClient.ts'
|
||||
import type { TrackSegment } from '../../vessel-map'
|
||||
import type { SearchMode, HitDetail, AreaSearchSummary } from '../types/areaSearch.types'
|
||||
import type { VesselTrackResult } from '../../../api/gisApi.ts'
|
||||
|
||||
interface AreaSearchRequest {
|
||||
startTime: string
|
||||
endTime: string
|
||||
mode: SearchMode
|
||||
polygons: { id: string; name: string; coordinates: number[][] }[]
|
||||
}
|
||||
|
||||
interface AreaSearchRawResponse {
|
||||
tracks: VesselTrackResult[]
|
||||
hitDetails: Record<string, RawHitDetail[]>
|
||||
summary: AreaSearchSummary | null
|
||||
}
|
||||
|
||||
interface RawHitDetail {
|
||||
polygonId: string
|
||||
polygonName?: string
|
||||
entryTimestamp?: number | string | null
|
||||
exitTimestamp?: number | string | null
|
||||
hitPointCount?: number
|
||||
visitIndex?: number
|
||||
}
|
||||
|
||||
/** 타임스탬프 초→밀리초 변환 */
|
||||
function toMs(ts: number | string | null | undefined): number | null {
|
||||
if (ts == null) return null
|
||||
const num = typeof ts === 'number' ? ts : parseInt(ts, 10)
|
||||
if (isNaN(num)) return null
|
||||
return num < 10_000_000_000 ? num * 1000 : num
|
||||
}
|
||||
|
||||
/** CompactVesselTrack → TrackSegment 변환 */
|
||||
function toTrackSegment(raw: VesselTrackResult): TrackSegment {
|
||||
return {
|
||||
vesselId: raw.vesselId,
|
||||
shipName: raw.shipName,
|
||||
shipKindCode: raw.shipKindCode,
|
||||
nationalCode: raw.nationalCode,
|
||||
geometry: raw.geometry as [number, number][],
|
||||
timestampsMs: raw.timestamps.map((t) => {
|
||||
const n = typeof t === 'number' ? t : parseInt(t, 10)
|
||||
return n < 10_000_000_000 ? n * 1000 : n
|
||||
}),
|
||||
speeds: raw.speeds,
|
||||
totalDistance: raw.totalDistance,
|
||||
avgSpeed: raw.avgSpeed,
|
||||
maxSpeed: raw.maxSpeed,
|
||||
pointCount: raw.pointCount ?? raw.geometry.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 이진 탐색 기반 위치 보간 (hitDetail 진입/진출 위치 계산) */
|
||||
function interpolatePositionAtTime(track: TrackSegment, targetTime: number | null): number[] | null {
|
||||
if (!targetTime || track.timestampsMs.length === 0) return null
|
||||
const ts = track.timestampsMs
|
||||
const geom = track.geometry
|
||||
|
||||
if (targetTime <= ts[0]) return [...geom[0]]
|
||||
if (targetTime >= ts[ts.length - 1]) return [...geom[geom.length - 1]]
|
||||
|
||||
let lo = 0, hi = ts.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (ts[mid] < targetTime) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
|
||||
const idx1 = Math.max(0, lo - 1)
|
||||
const idx2 = lo
|
||||
if (idx1 === idx2 || ts[idx1] === ts[idx2]) return [...geom[idx1]]
|
||||
|
||||
const ratio = (targetTime - ts[idx1]) / (ts[idx2] - ts[idx1])
|
||||
return [
|
||||
geom[idx1][0] + (geom[idx2][0] - geom[idx1][0]) * ratio,
|
||||
geom[idx1][1] + (geom[idx2][1] - geom[idx1][1]) * ratio,
|
||||
]
|
||||
}
|
||||
|
||||
export async function fetchAreaSearch(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
mode: SearchMode,
|
||||
polygons: { id: string; name: string; coordinates: number[][] }[],
|
||||
): Promise<{
|
||||
tracks: TrackSegment[]
|
||||
hitDetails: Record<string, HitDetail[]>
|
||||
summary: AreaSearchSummary | null
|
||||
}> {
|
||||
const request: AreaSearchRequest = {
|
||||
startTime: startTime.length === 16 ? startTime + ':00' : startTime,
|
||||
endTime: endTime.length === 16 ? endTime + ':00' : endTime,
|
||||
mode,
|
||||
polygons,
|
||||
}
|
||||
|
||||
const result = await postJson<AreaSearchRawResponse>('/api/v2/tracks/area-search', request)
|
||||
|
||||
const tracks = (result.tracks || []).map(toTrackSegment)
|
||||
const trackMap = new Map(tracks.map((t) => [t.vesselId, t]))
|
||||
|
||||
const hitDetails: Record<string, HitDetail[]> = {}
|
||||
for (const [vesselId, rawHits] of Object.entries(result.hitDetails || {})) {
|
||||
const track = trackMap.get(vesselId)
|
||||
hitDetails[vesselId] = (rawHits || []).map((h) => {
|
||||
const entryMs = toMs(h.entryTimestamp)
|
||||
const exitMs = toMs(h.exitTimestamp)
|
||||
return {
|
||||
polygonId: h.polygonId,
|
||||
polygonName: h.polygonName,
|
||||
visitIndex: h.visitIndex ?? 1,
|
||||
entryTimestamp: entryMs,
|
||||
exitTimestamp: exitMs,
|
||||
hitPointCount: h.hitPointCount,
|
||||
entryPosition: track ? interpolatePositionAtTime(track, entryMs) : null,
|
||||
exitPosition: track ? interpolatePositionAtTime(track, exitMs) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { tracks, hitDetails, summary: result.summary }
|
||||
}
|
||||
112
frontend/src/features/area-search/services/stsApi.ts
Normal file
112
frontend/src/features/area-search/services/stsApi.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { postJson } from '../../../api/httpClient.ts'
|
||||
import type { TrackSegment } from '../../vessel-map'
|
||||
import type { StsContact, StsSummary } from '../types/sts.types'
|
||||
import type { VesselTrackResult } from '../../../api/gisApi.ts'
|
||||
|
||||
interface StsSearchRequest {
|
||||
startTime: string
|
||||
endTime: string
|
||||
polygon: { id: string; name: string; coordinates: number[][] }
|
||||
minContactDurationMinutes: number
|
||||
maxContactDistanceMeters: number
|
||||
}
|
||||
|
||||
interface StsRawResponse {
|
||||
contacts: StsRawContact[]
|
||||
tracks: VesselTrackResult[]
|
||||
summary: StsSummary | null
|
||||
}
|
||||
|
||||
interface StsRawContact {
|
||||
vessel1: Record<string, unknown>
|
||||
vessel2: Record<string, unknown>
|
||||
contactStartTimestamp?: number | null
|
||||
contactEndTimestamp?: number | null
|
||||
contactDurationMinutes?: number
|
||||
contactPointCount?: number
|
||||
avgDistanceMeters?: number
|
||||
minDistanceMeters: number
|
||||
maxDistanceMeters: number
|
||||
contactCenterPoint?: [number, number]
|
||||
indicators?: Record<string, boolean>
|
||||
}
|
||||
|
||||
function toMs(ts: number | string | null | undefined): number | null {
|
||||
if (ts == null) return null
|
||||
const num = typeof ts === 'number' ? ts : parseInt(String(ts), 10)
|
||||
if (isNaN(num)) return null
|
||||
return num < 10_000_000_000 ? num * 1000 : num
|
||||
}
|
||||
|
||||
function toTrackSegment(raw: VesselTrackResult): TrackSegment {
|
||||
return {
|
||||
vesselId: raw.vesselId,
|
||||
shipName: raw.shipName,
|
||||
shipKindCode: raw.shipKindCode,
|
||||
nationalCode: raw.nationalCode,
|
||||
geometry: raw.geometry as [number, number][],
|
||||
timestampsMs: raw.timestamps.map((t) => {
|
||||
const n = typeof t === 'number' ? t : parseInt(t, 10)
|
||||
return n < 10_000_000_000 ? n * 1000 : n
|
||||
}),
|
||||
speeds: raw.speeds,
|
||||
totalDistance: raw.totalDistance,
|
||||
avgSpeed: raw.avgSpeed,
|
||||
maxSpeed: raw.maxSpeed,
|
||||
pointCount: raw.pointCount ?? raw.geometry.length,
|
||||
}
|
||||
}
|
||||
|
||||
function convertVessel(raw: Record<string, unknown>) {
|
||||
return {
|
||||
vesselId: String(raw.vesselId ?? ''),
|
||||
vesselName: raw.vesselName as string | undefined,
|
||||
shipType: raw.shipType as string | undefined,
|
||||
shipKindCode: raw.shipKindCode as string | undefined,
|
||||
nationalCode: raw.nationalCode as string | undefined,
|
||||
insidePolygonStartTs: toMs(raw.insidePolygonStartTs as number | null),
|
||||
insidePolygonEndTs: toMs(raw.insidePolygonEndTs as number | null),
|
||||
insidePolygonDurationMinutes: raw.insidePolygonDurationMinutes as number | undefined,
|
||||
estimatedAvgSpeedKnots: raw.estimatedAvgSpeedKnots as number | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVesselContacts(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
polygon: { id: string; name: string; coordinates: number[][] },
|
||||
minContactDurationMinutes: number,
|
||||
maxContactDistanceMeters: number,
|
||||
): Promise<{
|
||||
contacts: StsContact[]
|
||||
tracks: TrackSegment[]
|
||||
summary: StsSummary | null
|
||||
}> {
|
||||
const request: StsSearchRequest = {
|
||||
startTime: startTime.length === 16 ? startTime + ':00' : startTime,
|
||||
endTime: endTime.length === 16 ? endTime + ':00' : endTime,
|
||||
polygon,
|
||||
minContactDurationMinutes,
|
||||
maxContactDistanceMeters,
|
||||
}
|
||||
|
||||
const result = await postJson<StsRawResponse>('/api/v2/tracks/vessel-contacts', request)
|
||||
|
||||
const tracks = (result.tracks || []).map(toTrackSegment)
|
||||
|
||||
const contacts: StsContact[] = (result.contacts || []).map((c) => ({
|
||||
vessel1: convertVessel(c.vessel1),
|
||||
vessel2: convertVessel(c.vessel2),
|
||||
contactStartTimestamp: toMs(c.contactStartTimestamp),
|
||||
contactEndTimestamp: toMs(c.contactEndTimestamp),
|
||||
contactDurationMinutes: c.contactDurationMinutes,
|
||||
contactPointCount: c.contactPointCount,
|
||||
avgDistanceMeters: c.avgDistanceMeters,
|
||||
minDistanceMeters: c.minDistanceMeters,
|
||||
maxDistanceMeters: c.maxDistanceMeters,
|
||||
contactCenterPoint: c.contactCenterPoint,
|
||||
indicators: c.indicators,
|
||||
}))
|
||||
|
||||
return { contacts, tracks, summary: result.summary }
|
||||
}
|
||||
121
frontend/src/features/area-search/stores/animationStore.ts
Normal file
121
frontend/src/features/area-search/stores/animationStore.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { create } from 'zustand'
|
||||
import { PLAYBACK_SPEEDS } from '../types/areaSearch.types'
|
||||
|
||||
interface AnimationState {
|
||||
isPlaying: boolean
|
||||
currentTime: number // Unix epoch ms
|
||||
startTime: number
|
||||
endTime: number
|
||||
playbackSpeed: number // 1x ~ 1000x
|
||||
loop: boolean
|
||||
animationFrameId: number | null
|
||||
lastFrameTime: number
|
||||
|
||||
play: () => void
|
||||
pause: () => void
|
||||
stop: () => void
|
||||
setPlaybackSpeed: (speed: number) => void
|
||||
setCurrentTime: (time: number) => void
|
||||
setProgressByRatio: (ratio: number) => void
|
||||
setTimeRange: (start: number, end: number) => void
|
||||
toggleLoop: () => void
|
||||
getProgress: () => number // 0~100
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export { PLAYBACK_SPEEDS }
|
||||
|
||||
export const useAreaAnimationStore = create<AnimationState>((set, get) => ({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
playbackSpeed: 100,
|
||||
loop: false,
|
||||
animationFrameId: null,
|
||||
lastFrameTime: 0,
|
||||
|
||||
play: () => {
|
||||
const state = get()
|
||||
if (state.isPlaying) return
|
||||
if (state.startTime >= state.endTime) return
|
||||
|
||||
if (state.currentTime >= state.endTime) {
|
||||
set({ currentTime: state.startTime })
|
||||
}
|
||||
|
||||
set({ isPlaying: true, lastFrameTime: performance.now() })
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
const s = get()
|
||||
if (!s.isPlaying) return
|
||||
|
||||
const deltaMs = timestamp - s.lastFrameTime
|
||||
const increment = (deltaMs / 1000) * s.playbackSpeed * 1000
|
||||
let newTime = s.currentTime + increment
|
||||
|
||||
if (newTime > s.endTime) {
|
||||
if (s.loop) {
|
||||
newTime = s.startTime
|
||||
} else {
|
||||
set({ isPlaying: false, currentTime: s.endTime, animationFrameId: null })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
set({ currentTime: newTime, lastFrameTime: timestamp })
|
||||
const frameId = requestAnimationFrame(animate)
|
||||
set({ animationFrameId: frameId })
|
||||
}
|
||||
|
||||
const frameId = requestAnimationFrame(animate)
|
||||
set({ animationFrameId: frameId })
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
const { animationFrameId } = get()
|
||||
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId)
|
||||
set({ isPlaying: false, animationFrameId: null })
|
||||
},
|
||||
|
||||
stop: () => {
|
||||
const { animationFrameId, startTime } = get()
|
||||
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId)
|
||||
set({ isPlaying: false, animationFrameId: null, currentTime: startTime })
|
||||
},
|
||||
|
||||
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
|
||||
|
||||
setCurrentTime: (time) => set({ currentTime: time }),
|
||||
|
||||
setProgressByRatio: (ratio) => {
|
||||
const { startTime, endTime } = get()
|
||||
const time = startTime + (endTime - startTime) * Math.max(0, Math.min(1, ratio))
|
||||
set({ currentTime: time })
|
||||
},
|
||||
|
||||
setTimeRange: (start, end) => set({ startTime: start, endTime: end, currentTime: start }),
|
||||
|
||||
toggleLoop: () => set((s) => ({ loop: !s.loop })),
|
||||
|
||||
getProgress: () => {
|
||||
const { currentTime, startTime, endTime } = get()
|
||||
if (endTime <= startTime) return 0
|
||||
return ((currentTime - startTime) / (endTime - startTime)) * 100
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
const { animationFrameId } = get()
|
||||
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId)
|
||||
set({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
playbackSpeed: 100,
|
||||
loop: false,
|
||||
animationFrameId: null,
|
||||
lastFrameTime: 0,
|
||||
})
|
||||
},
|
||||
}))
|
||||
261
frontend/src/features/area-search/stores/areaSearchStore.ts
Normal file
261
frontend/src/features/area-search/stores/areaSearchStore.ts
Normal file
@ -0,0 +1,261 @@
|
||||
import { create } from 'zustand'
|
||||
import type { TrackSegment, InterpolatedPosition } from '../../vessel-map'
|
||||
import type { Zone, ZoneDrawType, SearchMode, HitDetail, AreaSearchSummary } from '../types/areaSearch.types'
|
||||
import { ZONE_NAMES, MAX_ZONES } from '../types/areaSearch.types'
|
||||
|
||||
let zoneIdCounter = 0
|
||||
|
||||
interface AreaSearchState {
|
||||
// 구역 관리
|
||||
zones: Zone[]
|
||||
activeDrawType: ZoneDrawType | null
|
||||
selectedZoneId: string | null
|
||||
|
||||
// 검색 조건
|
||||
searchMode: SearchMode
|
||||
startTime: string // 'YYYY-MM-DDTHH:mm'
|
||||
endTime: string
|
||||
|
||||
// 검색 결과
|
||||
tracks: TrackSegment[]
|
||||
hitDetails: Record<string, HitDetail[]>
|
||||
summary: AreaSearchSummary | null
|
||||
isLoading: boolean
|
||||
queryCompleted: boolean
|
||||
|
||||
// UI 상태
|
||||
disabledVesselIds: Set<string>
|
||||
highlightedVesselId: string | null
|
||||
showPaths: boolean
|
||||
showTrail: boolean
|
||||
|
||||
// 구역 관리 액션
|
||||
addZone: (zone: Pick<Zone, 'type' | 'coordinates' | 'terraDrawFeatureId'>) => void
|
||||
removeZone: (zoneId: string) => void
|
||||
clearZones: () => void
|
||||
reorderZones: (from: number, to: number) => void
|
||||
selectZone: (zoneId: string | null) => void
|
||||
updateZoneGeometry: (zoneId: string, coordinates: number[][]) => void
|
||||
setActiveDrawType: (type: ZoneDrawType | null) => void
|
||||
|
||||
// 검색
|
||||
setSearchMode: (mode: SearchMode) => void
|
||||
setStartTime: (time: string) => void
|
||||
setEndTime: (time: string) => void
|
||||
|
||||
// 결과
|
||||
setResults: (tracks: TrackSegment[], hitDetails: Record<string, HitDetail[]>, summary: AreaSearchSummary | null) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
|
||||
// 필터
|
||||
toggleVesselEnabled: (vesselId: string) => void
|
||||
setHighlightedVesselId: (id: string | null) => void
|
||||
|
||||
// 파생
|
||||
getEnabledTracks: () => TrackSegment[]
|
||||
getCurrentPositions: (currentTime: number) => InterpolatedPosition[]
|
||||
|
||||
// 초기화
|
||||
clearResults: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/** 위치 보간 커서 캐시 (React 외부) */
|
||||
const positionCursors = new Map<string, number>()
|
||||
|
||||
// 기본 시간 범위: D-7 00:00 ~ D-1 23:59
|
||||
function defaultStartTime(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 7)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function defaultEndTime(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
d.setHours(23, 59, 0, 0)
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export const useAreaSearchStore = create<AreaSearchState>((set, get) => ({
|
||||
zones: [],
|
||||
activeDrawType: null,
|
||||
selectedZoneId: null,
|
||||
searchMode: 'SEQUENTIAL',
|
||||
startTime: defaultStartTime(),
|
||||
endTime: defaultEndTime(),
|
||||
tracks: [],
|
||||
hitDetails: {},
|
||||
summary: null,
|
||||
isLoading: false,
|
||||
queryCompleted: false,
|
||||
disabledVesselIds: new Set(),
|
||||
highlightedVesselId: null,
|
||||
showPaths: true,
|
||||
showTrail: true,
|
||||
|
||||
addZone: (zone) => {
|
||||
const { zones } = get()
|
||||
if (zones.length >= MAX_ZONES) return
|
||||
|
||||
const usedColors = new Set(zones.map((z) => z.colorIndex))
|
||||
let colorIndex = 0
|
||||
while (usedColors.has(colorIndex)) colorIndex++
|
||||
|
||||
const newZone: Zone = {
|
||||
...zone,
|
||||
id: `zone-${++zoneIdCounter}`,
|
||||
name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`,
|
||||
colorIndex,
|
||||
}
|
||||
|
||||
set({ zones: [...zones, newZone], activeDrawType: null })
|
||||
},
|
||||
|
||||
removeZone: (zoneId) => {
|
||||
set((s) => ({ zones: s.zones.filter((z) => z.id !== zoneId) }))
|
||||
},
|
||||
|
||||
clearZones: () => set({ zones: [] }),
|
||||
|
||||
reorderZones: (from, to) => {
|
||||
const newZones = [...get().zones]
|
||||
const [moved] = newZones.splice(from, 1)
|
||||
newZones.splice(to, 0, moved)
|
||||
set({ zones: newZones })
|
||||
},
|
||||
|
||||
selectZone: (zoneId) => set({ selectedZoneId: zoneId }),
|
||||
|
||||
updateZoneGeometry: (zoneId, coordinates) => {
|
||||
set((s) => ({
|
||||
zones: s.zones.map((z) => (z.id === zoneId ? { ...z, coordinates } : z)),
|
||||
}))
|
||||
},
|
||||
|
||||
setActiveDrawType: (type) => set({ activeDrawType: type }),
|
||||
setSearchMode: (mode) => set({ searchMode: mode }),
|
||||
setStartTime: (time) => set({ startTime: time }),
|
||||
setEndTime: (time) => set({ endTime: time }),
|
||||
|
||||
setResults: (tracks, hitDetails, summary) => {
|
||||
positionCursors.clear()
|
||||
set({ tracks, hitDetails, summary, queryCompleted: true, isLoading: false, disabledVesselIds: new Set() })
|
||||
},
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
toggleVesselEnabled: (vesselId) => {
|
||||
const next = new Set(get().disabledVesselIds)
|
||||
if (next.has(vesselId)) next.delete(vesselId)
|
||||
else next.add(vesselId)
|
||||
set({ disabledVesselIds: next })
|
||||
},
|
||||
|
||||
setHighlightedVesselId: (id) => set({ highlightedVesselId: id }),
|
||||
|
||||
getEnabledTracks: () => {
|
||||
const { tracks, disabledVesselIds } = get()
|
||||
return tracks.filter((t) => !disabledVesselIds.has(t.vesselId))
|
||||
},
|
||||
|
||||
/** 커서 기반 위치 보간 — O(1) 재생, O(log n) seek */
|
||||
getCurrentPositions: (currentTime) => {
|
||||
const { tracks, disabledVesselIds } = get()
|
||||
const positions: InterpolatedPosition[] = []
|
||||
|
||||
for (const track of tracks) {
|
||||
if (disabledVesselIds.has(track.vesselId)) continue
|
||||
const ts = track.timestampsMs
|
||||
if (ts.length === 0) continue
|
||||
if (currentTime < ts[0] || currentTime > ts[ts.length - 1]) continue
|
||||
|
||||
let cursor = positionCursors.get(track.vesselId)
|
||||
if (cursor === undefined || cursor >= ts.length || (cursor > 0 && ts[cursor - 1] > currentTime)) {
|
||||
let lo = 0, hi = ts.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (ts[mid] < currentTime) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
cursor = lo
|
||||
} else {
|
||||
while (cursor < ts.length - 1 && ts[cursor] < currentTime) cursor++
|
||||
}
|
||||
positionCursors.set(track.vesselId, cursor)
|
||||
|
||||
const idx1 = Math.max(0, cursor - 1)
|
||||
const idx2 = Math.min(ts.length - 1, cursor)
|
||||
let lon: number, lat: number, heading: number, speed: number
|
||||
|
||||
if (idx1 === idx2 || ts[idx1] === ts[idx2]) {
|
||||
lon = track.geometry[idx1][0]
|
||||
lat = track.geometry[idx1][1]
|
||||
heading = 0
|
||||
speed = track.speeds[idx1] || 0
|
||||
} else {
|
||||
const ratio = (currentTime - ts[idx1]) / (ts[idx2] - ts[idx1])
|
||||
const p1 = track.geometry[idx1]
|
||||
const p2 = track.geometry[idx2]
|
||||
lon = p1[0] + (p2[0] - p1[0]) * ratio
|
||||
lat = p1[1] + (p2[1] - p1[1]) * ratio
|
||||
speed = (track.speeds[idx1] || 0) + ((track.speeds[idx2] || 0) - (track.speeds[idx1] || 0)) * ratio
|
||||
|
||||
const dLon = ((p2[0] - p1[0]) * Math.PI) / 180
|
||||
const lat1R = (p1[1] * Math.PI) / 180
|
||||
const lat2R = (p2[1] * Math.PI) / 180
|
||||
const y = Math.sin(dLon) * Math.cos(lat2R)
|
||||
const x = Math.cos(lat1R) * Math.sin(lat2R) - Math.sin(lat1R) * Math.cos(lat2R) * Math.cos(dLon)
|
||||
heading = (Math.atan2(y, x) * 180) / Math.PI
|
||||
if (heading < 0) heading += 360
|
||||
}
|
||||
|
||||
positions.push({
|
||||
vesselId: track.vesselId,
|
||||
lon,
|
||||
lat,
|
||||
heading,
|
||||
speed,
|
||||
shipName: track.shipName || '',
|
||||
shipKindCode: track.shipKindCode || '',
|
||||
})
|
||||
}
|
||||
|
||||
return positions
|
||||
},
|
||||
|
||||
clearResults: () => {
|
||||
positionCursors.clear()
|
||||
set({
|
||||
tracks: [],
|
||||
hitDetails: {},
|
||||
summary: null,
|
||||
queryCompleted: false,
|
||||
disabledVesselIds: new Set(),
|
||||
highlightedVesselId: null,
|
||||
})
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
positionCursors.clear()
|
||||
zoneIdCounter = 0
|
||||
set({
|
||||
zones: [],
|
||||
activeDrawType: null,
|
||||
selectedZoneId: null,
|
||||
searchMode: 'SEQUENTIAL',
|
||||
startTime: defaultStartTime(),
|
||||
endTime: defaultEndTime(),
|
||||
tracks: [],
|
||||
hitDetails: {},
|
||||
summary: null,
|
||||
isLoading: false,
|
||||
queryCompleted: false,
|
||||
disabledVesselIds: new Set(),
|
||||
highlightedVesselId: null,
|
||||
showPaths: true,
|
||||
showTrail: true,
|
||||
})
|
||||
},
|
||||
}))
|
||||
188
frontend/src/features/area-search/stores/stsStore.ts
Normal file
188
frontend/src/features/area-search/stores/stsStore.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { create } from 'zustand'
|
||||
import type { TrackSegment, InterpolatedPosition } from '../../vessel-map'
|
||||
import type { StsContact, StsGroupedContact, StsSummary } from '../types/sts.types'
|
||||
import { STS_DEFAULTS, groupContactsByPair } from '../types/sts.types'
|
||||
|
||||
interface StsState {
|
||||
// 파라미터
|
||||
minContactDurationMinutes: number
|
||||
maxContactDistanceMeters: number
|
||||
|
||||
// 결과
|
||||
contacts: StsContact[]
|
||||
groupedContacts: StsGroupedContact[]
|
||||
tracks: TrackSegment[]
|
||||
summary: StsSummary | null
|
||||
isLoading: boolean
|
||||
queryCompleted: boolean
|
||||
|
||||
// UI
|
||||
highlightedGroupIndex: number | null
|
||||
disabledGroupIndices: Set<number>
|
||||
expandedGroupIndex: number | null
|
||||
showPaths: boolean
|
||||
showTrail: boolean
|
||||
|
||||
// 액션
|
||||
setMinContactDuration: (val: number) => void
|
||||
setMaxContactDistance: (val: number) => void
|
||||
setResults: (result: { contacts: StsContact[]; tracks: TrackSegment[]; summary: StsSummary | null }) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setHighlightedGroupIndex: (idx: number | null) => void
|
||||
setExpandedGroupIndex: (idx: number | null) => void
|
||||
toggleGroupEnabled: (idx: number) => void
|
||||
|
||||
// 파생
|
||||
getDisabledVesselIds: () => Set<string>
|
||||
getCurrentPositions: (currentTime: number) => InterpolatedPosition[]
|
||||
|
||||
clearResults: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const stsCursors = new Map<string, number>()
|
||||
|
||||
export const useStsStore = create<StsState>((set, get) => ({
|
||||
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
|
||||
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
|
||||
contacts: [],
|
||||
groupedContacts: [],
|
||||
tracks: [],
|
||||
summary: null,
|
||||
isLoading: false,
|
||||
queryCompleted: false,
|
||||
highlightedGroupIndex: null,
|
||||
disabledGroupIndices: new Set(),
|
||||
expandedGroupIndex: null,
|
||||
showPaths: true,
|
||||
showTrail: true,
|
||||
|
||||
setMinContactDuration: (val) => set({ minContactDurationMinutes: val }),
|
||||
setMaxContactDistance: (val) => set({ maxContactDistanceMeters: val }),
|
||||
|
||||
setResults: ({ contacts, tracks, summary }) => {
|
||||
stsCursors.clear()
|
||||
const groupedContacts = groupContactsByPair(contacts)
|
||||
set({ contacts, groupedContacts, tracks, summary, queryCompleted: true, isLoading: false, disabledGroupIndices: new Set() })
|
||||
},
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setHighlightedGroupIndex: (idx) => set({ highlightedGroupIndex: idx }),
|
||||
setExpandedGroupIndex: (idx) => set((s) => ({ expandedGroupIndex: s.expandedGroupIndex === idx ? null : idx })),
|
||||
|
||||
toggleGroupEnabled: (idx) => {
|
||||
const next = new Set(get().disabledGroupIndices)
|
||||
if (next.has(idx)) next.delete(idx)
|
||||
else next.add(idx)
|
||||
set({ disabledGroupIndices: next })
|
||||
},
|
||||
|
||||
/** 비활성 그룹에 속한 선박 ID 집합 */
|
||||
getDisabledVesselIds: () => {
|
||||
const { groupedContacts, disabledGroupIndices } = get()
|
||||
const ids = new Set<string>()
|
||||
disabledGroupIndices.forEach((idx) => {
|
||||
const g = groupedContacts[idx]
|
||||
if (g) {
|
||||
ids.add(g.vessel1.vesselId)
|
||||
ids.add(g.vessel2.vesselId)
|
||||
}
|
||||
})
|
||||
return ids
|
||||
},
|
||||
|
||||
/** 커서 기반 위치 보간 */
|
||||
getCurrentPositions: (currentTime) => {
|
||||
const { tracks } = get()
|
||||
const disabledIds = get().getDisabledVesselIds()
|
||||
const positions: InterpolatedPosition[] = []
|
||||
|
||||
for (const track of tracks) {
|
||||
if (disabledIds.has(track.vesselId)) continue
|
||||
const ts = track.timestampsMs
|
||||
if (ts.length === 0 || currentTime < ts[0] || currentTime > ts[ts.length - 1]) continue
|
||||
|
||||
let cursor = stsCursors.get(track.vesselId)
|
||||
if (cursor === undefined || cursor >= ts.length || (cursor > 0 && ts[cursor - 1] > currentTime)) {
|
||||
let lo = 0, hi = ts.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (ts[mid] < currentTime) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
cursor = lo
|
||||
} else {
|
||||
while (cursor < ts.length - 1 && ts[cursor] < currentTime) cursor++
|
||||
}
|
||||
stsCursors.set(track.vesselId, cursor)
|
||||
|
||||
const idx1 = Math.max(0, cursor - 1)
|
||||
const idx2 = Math.min(ts.length - 1, cursor)
|
||||
let lon: number, lat: number, heading: number, speed: number
|
||||
|
||||
if (idx1 === idx2 || ts[idx1] === ts[idx2]) {
|
||||
lon = track.geometry[idx1][0]
|
||||
lat = track.geometry[idx1][1]
|
||||
heading = 0
|
||||
speed = track.speeds[idx1] || 0
|
||||
} else {
|
||||
const ratio = (currentTime - ts[idx1]) / (ts[idx2] - ts[idx1])
|
||||
const p1 = track.geometry[idx1]
|
||||
const p2 = track.geometry[idx2]
|
||||
lon = p1[0] + (p2[0] - p1[0]) * ratio
|
||||
lat = p1[1] + (p2[1] - p1[1]) * ratio
|
||||
speed = (track.speeds[idx1] || 0) + ((track.speeds[idx2] || 0) - (track.speeds[idx1] || 0)) * ratio
|
||||
|
||||
const dLon = ((p2[0] - p1[0]) * Math.PI) / 180
|
||||
const lat1R = (p1[1] * Math.PI) / 180
|
||||
const lat2R = (p2[1] * Math.PI) / 180
|
||||
const y = Math.sin(dLon) * Math.cos(lat2R)
|
||||
const x = Math.cos(lat1R) * Math.sin(lat2R) - Math.sin(lat1R) * Math.cos(lat2R) * Math.cos(dLon)
|
||||
heading = (Math.atan2(y, x) * 180) / Math.PI
|
||||
if (heading < 0) heading += 360
|
||||
}
|
||||
|
||||
positions.push({
|
||||
vesselId: track.vesselId,
|
||||
lon, lat, heading, speed,
|
||||
shipName: track.shipName || '',
|
||||
shipKindCode: track.shipKindCode || '',
|
||||
})
|
||||
}
|
||||
|
||||
return positions
|
||||
},
|
||||
|
||||
clearResults: () => {
|
||||
stsCursors.clear()
|
||||
set({
|
||||
contacts: [],
|
||||
groupedContacts: [],
|
||||
tracks: [],
|
||||
summary: null,
|
||||
queryCompleted: false,
|
||||
disabledGroupIndices: new Set(),
|
||||
highlightedGroupIndex: null,
|
||||
expandedGroupIndex: null,
|
||||
})
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
stsCursors.clear()
|
||||
set({
|
||||
minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION,
|
||||
maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE,
|
||||
contacts: [],
|
||||
groupedContacts: [],
|
||||
tracks: [],
|
||||
summary: null,
|
||||
isLoading: false,
|
||||
queryCompleted: false,
|
||||
highlightedGroupIndex: null,
|
||||
disabledGroupIndices: new Set(),
|
||||
expandedGroupIndex: null,
|
||||
showPaths: true,
|
||||
showTrail: true,
|
||||
})
|
||||
},
|
||||
}))
|
||||
64
frontend/src/features/area-search/types/areaSearch.types.ts
Normal file
64
frontend/src/features/area-search/types/areaSearch.types.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/** 구역 그리기 유형 */
|
||||
export type ZoneDrawType = 'Polygon' | 'Box' | 'Circle'
|
||||
|
||||
/** 검색 모드 */
|
||||
export type SearchMode = 'ANY' | 'ALL' | 'SEQUENTIAL'
|
||||
|
||||
/** 구역 정의 */
|
||||
export interface Zone {
|
||||
id: string
|
||||
name: string // 'A' | 'B' | 'C'
|
||||
type: ZoneDrawType
|
||||
coordinates: number[][] // [[lon, lat], ...] WGS84 (닫힌 폴리곤)
|
||||
colorIndex: number // 0, 1, 2
|
||||
terraDrawFeatureId?: string // terra-draw 내부 feature ID
|
||||
}
|
||||
|
||||
/** 구역 진입/진출 상세 */
|
||||
export interface HitDetail {
|
||||
polygonId: string
|
||||
polygonName?: string
|
||||
visitIndex: number // 1-based (동일 구역 재방문 시 증가)
|
||||
entryTimestamp: number | null // Unix epoch ms
|
||||
exitTimestamp: number | null // Unix epoch ms
|
||||
hitPointCount?: number
|
||||
entryPosition?: number[] | null // [lon, lat] (보간)
|
||||
exitPosition?: number[] | null // [lon, lat] (보간)
|
||||
}
|
||||
|
||||
/** 구역 검색 결과 요약 */
|
||||
export interface AreaSearchSummary {
|
||||
totalVessels: number
|
||||
totalPoints?: number
|
||||
mode?: SearchMode
|
||||
processingTimeMs?: number
|
||||
polygonIds?: string[]
|
||||
cachedDates?: string[]
|
||||
}
|
||||
|
||||
/** 구역 색상 정의 */
|
||||
export interface ZoneColor {
|
||||
fill: [number, number, number, number] // RGBA (0~255, alpha 0~1로 사용)
|
||||
stroke: [number, number, number, number]
|
||||
label: string // CSS color
|
||||
}
|
||||
|
||||
/** 구역 색상 팔레트 (최대 3개) */
|
||||
export const ZONE_COLORS: ZoneColor[] = [
|
||||
{ fill: [255, 59, 48, 0.15], stroke: [255, 59, 48, 0.8], label: '#FF3B30' }, // A: 빨강
|
||||
{ fill: [0, 199, 190, 0.15], stroke: [0, 199, 190, 0.8], label: '#00C7BE' }, // B: 청록
|
||||
{ fill: [255, 204, 0, 0.15], stroke: [255, 204, 0, 0.8], label: '#FFCC00' }, // C: 노랑
|
||||
]
|
||||
|
||||
export const ZONE_NAMES = ['A', 'B', 'C'] as const
|
||||
export const MAX_ZONES = 3
|
||||
|
||||
/** 검색 모드 옵션 */
|
||||
export const SEARCH_MODE_OPTIONS: { value: SearchMode; label: string; desc: string }[] = [
|
||||
{ value: 'ANY', label: '합집합', desc: '어느 구역이든 통과한 선박' },
|
||||
{ value: 'ALL', label: '교집합', desc: '모든 구역을 통과한 선박' },
|
||||
{ value: 'SEQUENTIAL', label: '순차통과', desc: '구역을 순서대로 통과한 선박' },
|
||||
]
|
||||
|
||||
/** 재생 배속 옵션 */
|
||||
export const PLAYBACK_SPEEDS = [1, 10, 50, 100, 500, 1000] as const
|
||||
152
frontend/src/features/area-search/types/sts.types.ts
Normal file
152
frontend/src/features/area-search/types/sts.types.ts
Normal file
@ -0,0 +1,152 @@
|
||||
/** STS 선박 정보 */
|
||||
export interface StsVessel {
|
||||
vesselId: string
|
||||
vesselName?: string
|
||||
shipType?: string
|
||||
shipKindCode?: string
|
||||
nationalCode?: string
|
||||
insidePolygonStartTs?: number | null // Unix epoch ms
|
||||
insidePolygonEndTs?: number | null
|
||||
insidePolygonDurationMinutes?: number
|
||||
estimatedAvgSpeedKnots?: number
|
||||
}
|
||||
|
||||
/** STS 환적 의심 지표 */
|
||||
export interface StsIndicators {
|
||||
lowSpeedContact?: boolean // 양 선박 저속 (< 3kn)
|
||||
differentVesselTypes?: boolean // 이종 선박 접촉
|
||||
differentNationalities?: boolean // 외국적 선박 접촉
|
||||
nightTimeContact?: boolean // 야간 접촉 (22:00~06:00 KST)
|
||||
[key: string]: boolean | undefined
|
||||
}
|
||||
|
||||
/** 단일 접촉 이벤트 */
|
||||
export interface StsContact {
|
||||
vessel1: StsVessel
|
||||
vessel2: StsVessel
|
||||
contactStartTimestamp: number | null // Unix epoch ms
|
||||
contactEndTimestamp: number | null
|
||||
contactDurationMinutes?: number
|
||||
contactPointCount?: number
|
||||
avgDistanceMeters?: number
|
||||
minDistanceMeters: number
|
||||
maxDistanceMeters: number
|
||||
contactCenterPoint?: [number, number] // [lon, lat]
|
||||
indicators?: StsIndicators
|
||||
}
|
||||
|
||||
/** 그룹화된 접촉 (동일 선박 쌍의 여러 접촉 통합) */
|
||||
export interface StsGroupedContact {
|
||||
pairKey: string // "vesselId1_vesselId2" (정렬)
|
||||
vessel1: StsVessel
|
||||
vessel2: StsVessel
|
||||
contacts: StsContact[]
|
||||
totalDurationMinutes: number
|
||||
avgDistanceMeters: number
|
||||
minDistanceMeters: number
|
||||
maxDistanceMeters: number
|
||||
contactCenterPoint?: [number, number]
|
||||
totalContactPointCount: number
|
||||
indicators: StsIndicators
|
||||
}
|
||||
|
||||
/** STS 검색 결과 요약 */
|
||||
export interface StsSummary {
|
||||
totalContactPairs: number
|
||||
totalVesselsInvolved: number
|
||||
totalVesselsInPolygon?: number
|
||||
processingTimeMs?: number
|
||||
polygonId?: string
|
||||
cachedDates?: string[]
|
||||
}
|
||||
|
||||
/** STS 파라미터 제한 */
|
||||
export const STS_DEFAULTS = {
|
||||
MIN_CONTACT_DURATION: 60, // 분
|
||||
MAX_CONTACT_DISTANCE: 500, // 미터
|
||||
} as const
|
||||
|
||||
export const STS_LIMITS = {
|
||||
DURATION_MIN: 30,
|
||||
DURATION_MAX: 360,
|
||||
DISTANCE_MIN: 50,
|
||||
DISTANCE_MAX: 5000,
|
||||
} as const
|
||||
|
||||
/** Indicator 위험도 색상 (RGBA) */
|
||||
export function getContactRiskColor(indicators?: StsIndicators): [number, number, number, number] {
|
||||
if (!indicators) return [150, 150, 150, 200]
|
||||
const count = Object.values(indicators).filter(Boolean).length
|
||||
if (count >= 3) return [231, 76, 60, 220] // 빨강
|
||||
if (count === 2) return [243, 156, 18, 220] // 주황
|
||||
if (count === 1) return [241, 196, 15, 200] // 노랑
|
||||
return [150, 150, 150, 200] // 회색
|
||||
}
|
||||
|
||||
/** Indicator 라벨 */
|
||||
export const INDICATOR_LABELS: Record<string, string> = {
|
||||
lowSpeedContact: '저속',
|
||||
differentVesselTypes: '이종',
|
||||
differentNationalities: '외국적',
|
||||
nightTimeContact: '야간',
|
||||
}
|
||||
|
||||
/** 접촉 쌍 그룹핑 */
|
||||
export function groupContactsByPair(contacts: StsContact[]): StsGroupedContact[] {
|
||||
const groupMap = new Map<string, StsGroupedContact>()
|
||||
|
||||
for (const contact of contacts) {
|
||||
const v1 = contact.vessel1.vesselId
|
||||
const v2 = contact.vessel2.vesselId
|
||||
const pairKey = v1 < v2 ? `${v1}_${v2}` : `${v2}_${v1}`
|
||||
|
||||
if (!groupMap.has(pairKey)) {
|
||||
groupMap.set(pairKey, {
|
||||
pairKey,
|
||||
vessel1: v1 < v2 ? contact.vessel1 : contact.vessel2,
|
||||
vessel2: v1 < v2 ? contact.vessel2 : contact.vessel1,
|
||||
contacts: [],
|
||||
totalDurationMinutes: 0,
|
||||
avgDistanceMeters: 0,
|
||||
minDistanceMeters: Infinity,
|
||||
maxDistanceMeters: 0,
|
||||
totalContactPointCount: 0,
|
||||
indicators: {},
|
||||
})
|
||||
}
|
||||
groupMap.get(pairKey)!.contacts.push(contact)
|
||||
}
|
||||
|
||||
return [...groupMap.values()].map((group) => {
|
||||
group.contacts.sort((a, b) => (a.contactStartTimestamp ?? 0) - (b.contactStartTimestamp ?? 0))
|
||||
|
||||
group.totalDurationMinutes = group.contacts.reduce((s, c) => s + (c.contactDurationMinutes || 0), 0)
|
||||
|
||||
// 가중 평균 거리 (contactPointCount 기준)
|
||||
let totalWeight = 0
|
||||
let weightedSum = 0
|
||||
for (const c of group.contacts) {
|
||||
const w = c.contactPointCount || 1
|
||||
weightedSum += (c.avgDistanceMeters || 0) * w
|
||||
totalWeight += w
|
||||
}
|
||||
group.avgDistanceMeters = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0
|
||||
group.minDistanceMeters = Math.min(...group.contacts.map((c) => c.minDistanceMeters))
|
||||
group.maxDistanceMeters = Math.max(...group.contacts.map((c) => c.maxDistanceMeters))
|
||||
group.totalContactPointCount = group.contacts.reduce((s, c) => s + (c.contactPointCount || 0), 0)
|
||||
group.contactCenterPoint = group.contacts[0]?.contactCenterPoint
|
||||
|
||||
// indicators OR 합산
|
||||
const merged: StsIndicators = {}
|
||||
for (const c of group.contacts) {
|
||||
if (c.indicators) {
|
||||
for (const [k, v] of Object.entries(c.indicators)) {
|
||||
if (v) merged[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
group.indicators = merged
|
||||
|
||||
return group
|
||||
})
|
||||
}
|
||||
111
frontend/src/features/area-search/utils/csvExport.ts
Normal file
111
frontend/src/features/area-search/utils/csvExport.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import type { TrackSegment } from '../../vessel-map'
|
||||
import { getShipKindLabel } from '../../vessel-map'
|
||||
import type { HitDetail, Zone } from '../types/areaSearch.types'
|
||||
|
||||
function escapeCsv(value: string | number): string {
|
||||
const str = String(value ?? '')
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
function formatTs(ms: number | null): 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())}`
|
||||
}
|
||||
|
||||
function formatPos(pos: number[] | null | undefined): string {
|
||||
if (!pos || pos.length < 2) return ''
|
||||
const lat = pos[1]
|
||||
const lon = pos[0]
|
||||
return `${Math.abs(lat).toFixed(4)}${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(4)}${lon >= 0 ? 'E' : 'W'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 구역 분석 결과 CSV 내보내기
|
||||
* - 다중 방문 동적 컬럼 지원
|
||||
* - BOM + UTF-8 (한글 엑셀 호환)
|
||||
*/
|
||||
export function exportAreaSearchCSV(
|
||||
tracks: TrackSegment[],
|
||||
hitDetails: Record<string, HitDetail[]>,
|
||||
zones: Zone[],
|
||||
): void {
|
||||
// 구역별 최대 방문 횟수
|
||||
const maxVisits: Record<string, number> = {}
|
||||
zones.forEach((z) => { maxVisits[z.id] = 1 })
|
||||
Object.values(hitDetails).forEach((hits) => {
|
||||
const countByZone: Record<string, number> = {}
|
||||
hits.forEach((h) => { countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1 })
|
||||
for (const [zoneId, count] of Object.entries(countByZone)) {
|
||||
maxVisits[zoneId] = Math.max(maxVisits[zoneId] || 0, count)
|
||||
}
|
||||
})
|
||||
|
||||
// 헤더
|
||||
const base = ['MMSI', '선박명', '선종', '국적', '포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)']
|
||||
const zoneHeaders: string[] = []
|
||||
zones.forEach((zone) => {
|
||||
const max = maxVisits[zone.id] || 1
|
||||
for (let v = 1; v <= max; v++) {
|
||||
const prefix = `구역${zone.name}${max > 1 ? `_${v}차` : ''}`
|
||||
zoneHeaders.push(`${prefix}_진입시각`, `${prefix}_진입위치`, `${prefix}_진출시각`, `${prefix}_진출위치`)
|
||||
}
|
||||
})
|
||||
|
||||
const headers = [...base, ...zoneHeaders]
|
||||
|
||||
// 행
|
||||
const rows = tracks.map((track) => {
|
||||
const row: (string | number)[] = [
|
||||
track.vesselId,
|
||||
track.shipName || '',
|
||||
getShipKindLabel(track.shipKindCode),
|
||||
track.nationalCode || '',
|
||||
track.pointCount ?? track.geometry.length,
|
||||
track.totalDistance?.toFixed(2) ?? '',
|
||||
track.avgSpeed?.toFixed(1) ?? '',
|
||||
track.maxSpeed?.toFixed(1) ?? '',
|
||||
]
|
||||
|
||||
const hits = hitDetails[track.vesselId] || []
|
||||
zones.forEach((zone) => {
|
||||
const max = maxVisits[zone.id] || 1
|
||||
const zoneHits = hits
|
||||
.filter((h) => h.polygonId === zone.id)
|
||||
.sort((a, b) => (a.visitIndex || 1) - (b.visitIndex || 1))
|
||||
|
||||
for (let v = 0; v < max; v++) {
|
||||
const hit = zoneHits[v]
|
||||
if (hit) {
|
||||
row.push(formatTs(hit.entryTimestamp), formatPos(hit.entryPosition), formatTs(hit.exitTimestamp), formatPos(hit.exitPosition))
|
||||
} else {
|
||||
row.push('', '', '', '')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return row
|
||||
})
|
||||
|
||||
// CSV 생성 + 다운로드
|
||||
const lines = [headers.map(escapeCsv).join(','), ...rows.map((r) => r.map(escapeCsv).join(','))]
|
||||
const csv = lines.join('\n')
|
||||
|
||||
const BOM = '\uFEFF'
|
||||
const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@ -127,6 +127,8 @@ const en = {
|
||||
'explorer.recentPositions': 'Recent Positions',
|
||||
'explorer.vesselTracks': 'Vessel Tracks',
|
||||
'explorer.viewportReplay': 'Viewport Replay',
|
||||
'explorer.areaSearch': 'Area Track Analysis',
|
||||
'explorer.stsAnalysis': 'STS Contact Analysis',
|
||||
'explorer.parameters': 'Parameters',
|
||||
'explorer.positionsDesc': 'Fetches vessels with position updates within 10 minutes.',
|
||||
'explorer.vesselDesc': 'Fetches tracks for specific vessels by MMSI list.',
|
||||
|
||||
@ -127,6 +127,8 @@ const ko = {
|
||||
'explorer.recentPositions': '최근 위치',
|
||||
'explorer.vesselTracks': '선박별 항적',
|
||||
'explorer.viewportReplay': '뷰포트 리플레이',
|
||||
'explorer.areaSearch': '구역 항적 분석',
|
||||
'explorer.stsAnalysis': 'STS 접촉 분석',
|
||||
'explorer.parameters': '파라미터',
|
||||
'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.',
|
||||
'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useI18n } from '../hooks/useI18n.ts'
|
||||
import MapContainer from '../components/map/MapContainer.tsx'
|
||||
import Sidebar from '../components/layout/Sidebar.tsx'
|
||||
@ -25,8 +25,17 @@ import {
|
||||
ReplaySetupPanel,
|
||||
ReplayControlPanel,
|
||||
} from '../features/viewport-replay'
|
||||
import {
|
||||
useAreaSearchStore,
|
||||
useStsStore,
|
||||
useAreaAnimationStore,
|
||||
useAreaSearchLayer,
|
||||
useStsLayer,
|
||||
AreaSearchPanel,
|
||||
TimelineControl,
|
||||
} from '../features/area-search'
|
||||
|
||||
type ApiMode = 'positions' | 'vessel' | 'replay'
|
||||
type ApiMode = 'positions' | 'vessel' | 'replay' | 'area-search' | 'sts'
|
||||
|
||||
/** 최근위치 — 선택된 선박의 원본 데이터 */
|
||||
function usePositionRawData() {
|
||||
@ -90,13 +99,57 @@ function useReplayRawData() {
|
||||
}, [vesselChunks, queryCompleted, receivedChunks])
|
||||
}
|
||||
|
||||
/** 구역분석 — 조회 결과 요약 */
|
||||
function useAreaSearchRawData() {
|
||||
const tracks = useAreaSearchStore((s) => s.tracks)
|
||||
const summary = useAreaSearchStore((s) => s.summary)
|
||||
|
||||
return useMemo(() => {
|
||||
if (tracks.length === 0) return null
|
||||
return {
|
||||
summary,
|
||||
vessels: tracks.map((t) => ({
|
||||
vesselId: t.vesselId,
|
||||
shipName: t.shipName,
|
||||
shipKindCode: t.shipKindCode,
|
||||
pointCount: t.geometry.length,
|
||||
totalDistance: t.totalDistance?.toFixed(2),
|
||||
avgSpeed: t.avgSpeed?.toFixed(1),
|
||||
})),
|
||||
}
|
||||
}, [tracks, summary])
|
||||
}
|
||||
|
||||
/** STS — 접촉 분석 결과 요약 */
|
||||
function useStsRawData() {
|
||||
const groupedContacts = useStsStore((s) => s.groupedContacts)
|
||||
const summary = useStsStore((s) => s.summary)
|
||||
|
||||
return useMemo(() => {
|
||||
if (groupedContacts.length === 0) return null
|
||||
return {
|
||||
summary,
|
||||
contactPairs: groupedContacts.map((g) => ({
|
||||
vessel1: g.vessel1.vesselName || g.vessel1.vesselId,
|
||||
vessel2: g.vessel2.vesselName || g.vessel2.vesselId,
|
||||
contacts: g.contacts.length,
|
||||
totalDuration: g.totalDurationMinutes,
|
||||
avgDistance: g.avgDistanceMeters,
|
||||
indicators: Object.entries(g.indicators).filter(([, v]) => v).map(([k]) => k),
|
||||
})),
|
||||
}
|
||||
}, [groupedContacts, summary])
|
||||
}
|
||||
|
||||
export default function ApiExplorer() {
|
||||
const { t } = useI18n()
|
||||
const [mode, setMode] = useState<ApiMode>('positions')
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null)
|
||||
const [zoom, setZoom] = useState(7)
|
||||
const [trackMmsi, setTrackMmsi] = useState<string | undefined>()
|
||||
const [replayLayers, setReplayLayers] = useState<Layer[]>([])
|
||||
const [areaLayers, setAreaLayers] = useState<Layer[]>([])
|
||||
const [stsLayers, setStsLayers] = useState<Layer[]>([])
|
||||
|
||||
const selectVessel = usePositionStore((s) => s.selectVessel)
|
||||
|
||||
@ -122,31 +175,72 @@ export default function ApiExplorer() {
|
||||
onLayersUpdate: handleReplayLayersUpdate,
|
||||
})
|
||||
|
||||
// Deck.gl 레이어 — area search
|
||||
const handleAreaLayersUpdate = useCallback((layers: Layer[]) => {
|
||||
setAreaLayers(layers)
|
||||
}, [])
|
||||
|
||||
useAreaSearchLayer({
|
||||
zoom,
|
||||
onLayersUpdate: handleAreaLayersUpdate,
|
||||
})
|
||||
|
||||
// Deck.gl 레이어 — STS
|
||||
const handleStsLayersUpdate = useCallback((layers: Layer[]) => {
|
||||
setStsLayers(layers)
|
||||
}, [])
|
||||
|
||||
useStsLayer({
|
||||
zoom,
|
||||
onLayersUpdate: handleStsLayersUpdate,
|
||||
})
|
||||
|
||||
const handleMapReady = useCallback((m: maplibregl.Map) => {
|
||||
mapRef.current = m
|
||||
setMapInstance(m)
|
||||
setZoom(m.getZoom())
|
||||
m.on('zoom', () => setZoom(m.getZoom()))
|
||||
}, [])
|
||||
|
||||
// 모드 전환 핸들러 (이전 모드 리소스 정리 포함)
|
||||
const handleModeChange = useCallback((newMode: ApiMode) => {
|
||||
setMode((prev) => {
|
||||
if (prev === 'area-search' && newMode !== 'area-search') {
|
||||
useAreaSearchStore.getState().clearResults()
|
||||
useAreaAnimationStore.getState().reset()
|
||||
}
|
||||
if (prev === 'sts' && newMode !== 'sts') {
|
||||
useStsStore.getState().clearResults()
|
||||
useAreaAnimationStore.getState().reset()
|
||||
}
|
||||
return newMode
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 최근위치 → 항적조회 전환
|
||||
const handleTrackQuery = useCallback((mmsi: string) => {
|
||||
setTrackMmsi(mmsi)
|
||||
setMode('vessel')
|
||||
handleModeChange('vessel')
|
||||
selectVessel(null)
|
||||
}, [selectVessel])
|
||||
}, [selectVessel, handleModeChange])
|
||||
|
||||
// Raw Data 훅
|
||||
const positionData = usePositionRawData()
|
||||
const trackData = useTrackRawData()
|
||||
const replayData = useReplayRawData()
|
||||
const areaSearchData = useAreaSearchRawData()
|
||||
const stsData = useStsRawData()
|
||||
|
||||
// 모드별 Deck.gl 레이어
|
||||
const deckLayers: Layer[] =
|
||||
mode === 'positions' ? positionLayers :
|
||||
mode === 'vessel' ? trackLayers :
|
||||
mode === 'replay' ? replayLayers :
|
||||
mode === 'area-search' ? areaLayers :
|
||||
mode === 'sts' ? stsLayers :
|
||||
[]
|
||||
|
||||
const isAreaMode = mode === 'area-search' || mode === 'sts'
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
@ -165,10 +259,12 @@ export default function ApiExplorer() {
|
||||
{ value: 'positions' as const, label: t('explorer.recentPositions') },
|
||||
{ value: 'vessel' as const, label: t('explorer.vesselTracks') },
|
||||
{ value: 'replay' as const, label: '뷰포트 리플레이' },
|
||||
{ value: 'area-search' as const, label: t('explorer.areaSearch') },
|
||||
{ value: 'sts' as const, label: t('explorer.stsAnalysis') },
|
||||
] as const).map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setMode(opt.value)}
|
||||
onClick={() => handleModeChange(opt.value)}
|
||||
className={`w-full rounded-md px-3 py-2 text-left text-sm transition ${
|
||||
mode === opt.value
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
@ -199,7 +295,13 @@ export default function ApiExplorer() {
|
||||
{mode === 'replay' && (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted">뷰포트 리플레이</div>
|
||||
<ReplaySetupPanel map={mapRef.current} />
|
||||
<ReplaySetupPanel map={mapInstance} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAreaMode && (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<AreaSearchPanel map={mapInstance} mode={mode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -225,6 +327,20 @@ export default function ApiExplorer() {
|
||||
emptyText="뷰포트 리플레이를 실행하세요"
|
||||
/>
|
||||
)}
|
||||
{mode === 'area-search' && (
|
||||
<RawDataPanel
|
||||
title="구역분석 결과"
|
||||
data={areaSearchData}
|
||||
emptyText="구역을 그리고 조회하세요"
|
||||
/>
|
||||
)}
|
||||
{mode === 'sts' && (
|
||||
<RawDataPanel
|
||||
title="STS 접촉 분석 결과"
|
||||
data={stsData}
|
||||
emptyText="구역을 그리고 조회하세요"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
@ -238,12 +354,15 @@ export default function ApiExplorer() {
|
||||
{mode === 'positions' && t('explorer.recentPositions')}
|
||||
{mode === 'vessel' && t('explorer.vesselTracks')}
|
||||
{mode === 'replay' && '뷰포트 리플레이'}
|
||||
{mode === 'area-search' && t('explorer.areaSearch')}
|
||||
{mode === 'sts' && t('explorer.stsAnalysis')}
|
||||
</div>
|
||||
|
||||
{/* 모드별 오버레이 */}
|
||||
{mode === 'positions' && <VesselPopup onTrackQuery={handleTrackQuery} />}
|
||||
{mode === 'vessel' && <TrackInfoPanel />}
|
||||
{mode === 'replay' && <ReplayControlPanel />}
|
||||
{isAreaMode && <TimelineControl />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user