Merge pull request 'feat: Ship-GIS 기능 이관 — 최근위치/선박항적/뷰포트 리플레이' (#62) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m0s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m0s
This commit is contained in:
커밋
137a22a411
1165
frontend/package-lock.json
generated
1165
frontend/package-lock.json
generated
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -10,11 +10,17 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@deck.gl/core": "^9.2.8",
|
||||||
|
"@deck.gl/geo-layers": "^9.2.8",
|
||||||
|
"@deck.gl/layers": "^9.2.8",
|
||||||
|
"@deck.gl/mapbox": "^9.2.8",
|
||||||
|
"@stomp/stompjs": "^7.3.0",
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@ -8,24 +8,37 @@ export interface HaeguBoundary {
|
|||||||
center_lat: number
|
center_lat: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CompactVesselTrack.java 기준 */
|
||||||
export interface VesselTrackResult {
|
export interface VesselTrackResult {
|
||||||
mmsi: string
|
vesselId: string
|
||||||
nationalCode: string
|
nationalCode: string
|
||||||
shipKindCode: string
|
shipKindCode?: string
|
||||||
|
shipName?: string
|
||||||
|
shipType?: string
|
||||||
geometry: number[][]
|
geometry: number[][]
|
||||||
timestamps: number[]
|
timestamps: string[] // 백엔드가 문자열 배열로 전송
|
||||||
speeds: number[]
|
speeds: number[]
|
||||||
|
totalDistance?: number
|
||||||
|
avgSpeed?: number
|
||||||
|
maxSpeed?: number
|
||||||
|
pointCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** RecentVesselPositionDto.java 기준 */
|
||||||
export interface RecentPosition {
|
export interface RecentPosition {
|
||||||
mmsi: string
|
mmsi: string
|
||||||
lat: number
|
imo?: number
|
||||||
lon: number
|
lon: number
|
||||||
|
lat: number
|
||||||
sog: number
|
sog: number
|
||||||
cog: number
|
cog: number
|
||||||
lastUpdate: string
|
shipNm?: string
|
||||||
vesselName?: string
|
shipTy?: string
|
||||||
shipKindCode?: string
|
shipKindCode?: string
|
||||||
|
nationalCode?: string
|
||||||
|
lastUpdate: string
|
||||||
|
shipImagePath?: string | null
|
||||||
|
shipImageCount?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const gisApi = {
|
export const gisApi = {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useRef, useEffect, useState } from 'react'
|
import { useRef, useEffect, useState } from 'react'
|
||||||
import maplibregl from 'maplibre-gl'
|
import maplibregl from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
|
import type { Layer } from '@deck.gl/core'
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import { useTheme } from '../../hooks/useTheme.ts'
|
import { useTheme } from '../../hooks/useTheme.ts'
|
||||||
import {
|
import {
|
||||||
MAP_CENTER,
|
MAP_CENTER,
|
||||||
@ -13,12 +15,14 @@ import {
|
|||||||
|
|
||||||
interface MapContainerProps {
|
interface MapContainerProps {
|
||||||
onMapReady?: (map: maplibregl.Map) => void
|
onMapReady?: (map: maplibregl.Map) => void
|
||||||
|
deckLayers?: Layer[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapContainer({ onMapReady, className = '' }: MapContainerProps) {
|
export default function MapContainer({ onMapReady, deckLayers, className = '' }: MapContainerProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
|
const overlayRef = useRef<MapboxOverlay | null>(null)
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
@ -41,6 +45,13 @@ export default function MapContainer({ onMapReady, className = '' }: MapContaine
|
|||||||
'bottom-right',
|
'bottom-right',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const overlay = new MapboxOverlay({
|
||||||
|
interleaved: false,
|
||||||
|
layers: [],
|
||||||
|
})
|
||||||
|
map.addControl(overlay)
|
||||||
|
overlayRef.current = overlay
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
setReady(true)
|
setReady(true)
|
||||||
@ -48,12 +59,19 @@ export default function MapContainer({ onMapReady, className = '' }: MapContaine
|
|||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
overlayRef.current = null
|
||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
map.remove()
|
map.remove()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/* Deck.gl 레이어 업데이트 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!overlayRef.current || !ready) return
|
||||||
|
overlayRef.current.setProps({ layers: deckLayers || [] })
|
||||||
|
}, [deckLayers, ready])
|
||||||
|
|
||||||
/* 테마 변경 시 스타일 교체 */
|
/* 테마 변경 시 스타일 교체 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || !ready) return
|
if (!mapRef.current || !ready) return
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
import { usePositionStore } from '../stores/positionStore'
|
||||||
|
import { getAllShipKinds } from '../../vessel-map/utils/shipKindColors'
|
||||||
|
|
||||||
|
const ALL_KINDS = getAllShipKinds()
|
||||||
|
|
||||||
|
export default function PositionFilterPanel() {
|
||||||
|
const kindVisibility = usePositionStore((s) => s.kindVisibility)
|
||||||
|
const toggleKindVisibility = usePositionStore((s) => s.toggleKindVisibility)
|
||||||
|
const setAllKindVisibility = usePositionStore((s) => s.setAllKindVisibility)
|
||||||
|
const positions = usePositionStore((s) => s.positions)
|
||||||
|
|
||||||
|
// 선종별 선박 수 계산
|
||||||
|
const kindCounts: Record<string, number> = {}
|
||||||
|
for (const p of positions.values()) {
|
||||||
|
const code = p.shipKindCode || ''
|
||||||
|
kindCounts[code] = (kindCounts[code] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const allVisible = ALL_KINDS.every((k) => kindVisibility[k.code] !== false)
|
||||||
|
const totalCount = positions.size
|
||||||
|
const visibleCount = ALL_KINDS.reduce((sum, k) => {
|
||||||
|
if (kindVisibility[k.code] === false) return sum
|
||||||
|
return sum + (kindCounts[k.code] || 0)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 요약 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{visibleCount} / {totalCount} 선박
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setAllKindVisibility(!allVisible)}
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{allVisible ? '전체 해제' : '전체 선택'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선종별 토글 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{ALL_KINDS.map((kind) => {
|
||||||
|
const count = kindCounts[kind.code] || 0
|
||||||
|
const visible = kindVisibility[kind.code] !== false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={kind.code}
|
||||||
|
onClick={() => toggleKindVisibility(kind.code)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition ${
|
||||||
|
visible
|
||||||
|
? 'text-foreground hover:bg-surface-hover'
|
||||||
|
: 'text-muted opacity-50 hover:bg-surface-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: visible ? kind.color : '#9CA3AF' }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{kind.label}</span>
|
||||||
|
<span className="text-muted tabular-nums">{count}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { Layer } from '@deck.gl/core'
|
||||||
|
import { usePositionStore } from '../stores/positionStore'
|
||||||
|
import { createVesselIconLayer } from '../../vessel-map'
|
||||||
|
import { getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors'
|
||||||
|
import type { VesselIconData, VesselPosition } from '../../vessel-map'
|
||||||
|
|
||||||
|
interface RecentPositionsLayerProps {
|
||||||
|
zoom: number
|
||||||
|
onVesselClick?: (mmsi: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIconData(p: VesselPosition, zoom: number): VesselIconData {
|
||||||
|
return {
|
||||||
|
mmsi: p.mmsi,
|
||||||
|
position: [p.lon, p.lat],
|
||||||
|
angle: p.cog,
|
||||||
|
icon: getIconKey(p.shipKindCode, p.sog),
|
||||||
|
size: getIconSize(zoom, p.shipKindCode, p.sog),
|
||||||
|
shipNm: p.shipNm,
|
||||||
|
shipKindCode: p.shipKindCode,
|
||||||
|
sog: p.sog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 위치 Deck.gl 레이어 생성 훅
|
||||||
|
* positionStore의 가시성 필터 적용 + 줌 기반 아이콘 크기
|
||||||
|
*/
|
||||||
|
export function useRecentPositionsLayer({
|
||||||
|
zoom,
|
||||||
|
onVesselClick,
|
||||||
|
}: RecentPositionsLayerProps): Layer[] {
|
||||||
|
const getVisiblePositions = usePositionStore((s) => s.getVisiblePositions)
|
||||||
|
const positions = usePositionStore((s) => s.positions)
|
||||||
|
const kindVisibility = usePositionStore((s) => s.kindVisibility)
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const visible = getVisiblePositions()
|
||||||
|
const data = visible.map((p) => toIconData(p, zoom))
|
||||||
|
|
||||||
|
return [
|
||||||
|
createVesselIconLayer({
|
||||||
|
id: 'recent-positions',
|
||||||
|
data,
|
||||||
|
onClick: (info) => {
|
||||||
|
if (info.object) onVesselClick?.(info.object.mmsi)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
// positions, kindVisibility를 deps에 포함하여 데이터 변경 시 재생성
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [positions, kindVisibility, zoom, onVesselClick, getVisiblePositions])
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
import { usePositionStore } from '../stores/positionStore'
|
||||||
|
import { getShipKindLabel, getShipKindColor } from '../../vessel-map/utils/shipKindColors'
|
||||||
|
|
||||||
|
interface VesselPopupProps {
|
||||||
|
onTrackQuery?: (mmsi: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VesselPopup({ onTrackQuery }: VesselPopupProps) {
|
||||||
|
const selectedMmsi = usePositionStore((s) => s.selectedMmsi)
|
||||||
|
const positions = usePositionStore((s) => s.positions)
|
||||||
|
const selectVessel = usePositionStore((s) => s.selectVessel)
|
||||||
|
|
||||||
|
if (!selectedMmsi) return null
|
||||||
|
|
||||||
|
const vessel = positions.get(selectedMmsi)
|
||||||
|
if (!vessel) return null
|
||||||
|
|
||||||
|
const kindLabel = getShipKindLabel(vessel.shipKindCode)
|
||||||
|
const kindColor = getShipKindColor(vessel.shipKindCode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-4 left-4 z-10 w-72 rounded-lg border border-border bg-surface p-3 shadow-lg">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: kindColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
{vessel.shipNm || vessel.mmsi}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => selectVessel(null)}
|
||||||
|
className="text-muted hover:text-foreground"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선박 정보 그리드 */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
|
||||||
|
<InfoRow label="MMSI" value={vessel.mmsi} />
|
||||||
|
{vessel.imo ? <InfoRow label="IMO" value={String(vessel.imo)} /> : null}
|
||||||
|
<InfoRow label="선종" value={kindLabel} />
|
||||||
|
<InfoRow label="국적" value={vessel.nationalCode || '-'} />
|
||||||
|
<InfoRow label="SOG" value={`${vessel.sog.toFixed(1)} kn`} />
|
||||||
|
<InfoRow label="COG" value={`${vessel.cog.toFixed(1)}\u00B0`} />
|
||||||
|
<InfoRow label="위치" value={`${vessel.lat.toFixed(4)}, ${vessel.lon.toFixed(4)}`} />
|
||||||
|
<InfoRow label="업데이트" value={vessel.lastUpdate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선박 사진 */}
|
||||||
|
{vessel.shipImagePath && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<img
|
||||||
|
src={vessel.shipImagePath}
|
||||||
|
alt={vessel.shipNm || vessel.mmsi}
|
||||||
|
className="h-20 w-full rounded object-cover"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 항적 조회 버튼 */}
|
||||||
|
{onTrackQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => onTrackQuery(vessel.mmsi)}
|
||||||
|
className="mt-2 w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-primary/90 transition"
|
||||||
|
>
|
||||||
|
항적 조회
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="text-muted">{label}</span>
|
||||||
|
<span className="text-foreground">{value}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { gisApi, type RecentPosition } from '../../../api/gisApi'
|
||||||
|
import { usePositionStore } from '../stores/positionStore'
|
||||||
|
import type { VesselPosition } from '../../vessel-map'
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
/** RecentPosition API 응답 → 내부 VesselPosition 변환 */
|
||||||
|
function toVesselPosition(r: RecentPosition): VesselPosition {
|
||||||
|
return {
|
||||||
|
mmsi: r.mmsi,
|
||||||
|
imo: r.imo,
|
||||||
|
lon: r.lon,
|
||||||
|
lat: r.lat,
|
||||||
|
sog: r.sog,
|
||||||
|
cog: r.cog,
|
||||||
|
shipNm: r.shipNm,
|
||||||
|
shipTy: r.shipTy,
|
||||||
|
shipKindCode: r.shipKindCode,
|
||||||
|
nationalCode: r.nationalCode,
|
||||||
|
lastUpdate: r.lastUpdate,
|
||||||
|
shipImagePath: r.shipImagePath,
|
||||||
|
shipImageCount: r.shipImageCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 위치 폴링 훅
|
||||||
|
* @param minutes API 조회 범위 (기본 10분)
|
||||||
|
* @param enabled 활성화 여부
|
||||||
|
*/
|
||||||
|
export function useRecentPositions(minutes = 10, enabled = true) {
|
||||||
|
const setPositions = usePositionStore((s) => s.setPositions)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const fetchPositions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await gisApi.getRecentPositions(minutes)
|
||||||
|
setPositions(data.map(toVesselPosition))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useRecentPositions] fetch failed:', err)
|
||||||
|
}
|
||||||
|
}, [minutes, setPositions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
fetchPositions()
|
||||||
|
timerRef.current = setInterval(fetchPositions, POLL_INTERVAL_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, fetchPositions])
|
||||||
|
|
||||||
|
return { refetch: fetchPositions }
|
||||||
|
}
|
||||||
5
frontend/src/features/recent-positions/index.ts
Normal file
5
frontend/src/features/recent-positions/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { usePositionStore } from './stores/positionStore'
|
||||||
|
export { useRecentPositions } from './hooks/useRecentPositions'
|
||||||
|
export { useRecentPositionsLayer } from './components/RecentPositionsLayer'
|
||||||
|
export { default as VesselPopup } from './components/VesselPopup'
|
||||||
|
export { default as PositionFilterPanel } from './components/PositionFilterPanel'
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { VesselPosition } from '../../vessel-map'
|
||||||
|
|
||||||
|
interface PositionState {
|
||||||
|
/** MMSI → 위치 맵 */
|
||||||
|
positions: Map<string, VesselPosition>
|
||||||
|
|
||||||
|
/** 선택된 MMSI (팝업 표시용) */
|
||||||
|
selectedMmsi: string | null
|
||||||
|
|
||||||
|
/** 선종별 가시성 필터 */
|
||||||
|
kindVisibility: Record<string, boolean>
|
||||||
|
|
||||||
|
/** 위치 데이터 전체 교체 */
|
||||||
|
setPositions: (list: VesselPosition[]) => void
|
||||||
|
|
||||||
|
/** 선박 선택/해제 */
|
||||||
|
selectVessel: (mmsi: string | null) => void
|
||||||
|
|
||||||
|
/** 특정 선종 토글 */
|
||||||
|
toggleKindVisibility: (kindCode: string) => void
|
||||||
|
|
||||||
|
/** 전체 선종 표시/숨김 */
|
||||||
|
setAllKindVisibility: (visible: boolean) => void
|
||||||
|
|
||||||
|
/** 가시성 필터 적용된 선박 목록 */
|
||||||
|
getVisiblePositions: () => VesselPosition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePositionStore = create<PositionState>((set, get) => ({
|
||||||
|
positions: new Map(),
|
||||||
|
selectedMmsi: null,
|
||||||
|
kindVisibility: {},
|
||||||
|
|
||||||
|
setPositions: (list) => {
|
||||||
|
const map = new Map<string, VesselPosition>()
|
||||||
|
for (const p of list) {
|
||||||
|
map.set(p.mmsi, p)
|
||||||
|
}
|
||||||
|
set({ positions: map })
|
||||||
|
},
|
||||||
|
|
||||||
|
selectVessel: (mmsi) => set({ selectedMmsi: mmsi }),
|
||||||
|
|
||||||
|
toggleKindVisibility: (kindCode) =>
|
||||||
|
set((state) => ({
|
||||||
|
kindVisibility: {
|
||||||
|
...state.kindVisibility,
|
||||||
|
[kindCode]: !(state.kindVisibility[kindCode] ?? true),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setAllKindVisibility: (visible) =>
|
||||||
|
set((state) => {
|
||||||
|
const next: Record<string, boolean> = {}
|
||||||
|
for (const code of Object.keys(state.kindVisibility)) {
|
||||||
|
next[code] = visible
|
||||||
|
}
|
||||||
|
return { kindVisibility: next }
|
||||||
|
}),
|
||||||
|
|
||||||
|
getVisiblePositions: () => {
|
||||||
|
const { positions, kindVisibility } = get()
|
||||||
|
const result: VesselPosition[] = []
|
||||||
|
for (const p of positions.values()) {
|
||||||
|
const code = p.shipKindCode || ''
|
||||||
|
if (kindVisibility[code] === false) continue
|
||||||
|
result.push(p)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
}))
|
||||||
BIN
frontend/src/features/vessel-map/assets/atlas.png
Normal file
BIN
frontend/src/features/vessel-map/assets/atlas.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 10 KiB |
117
frontend/src/features/vessel-map/constants.ts
Normal file
117
frontend/src/features/vessel-map/constants.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* vessel-map 공유 상수
|
||||||
|
* 선종 코드, 색상, 아이콘 매핑, 임계값
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- 선종 코드 (shipKindCode) ---
|
||||||
|
export const SHIP_KIND_FISHING = '000020'
|
||||||
|
export const SHIP_KIND_KCGV = '000021'
|
||||||
|
export const SHIP_KIND_PASSENGER = '000022'
|
||||||
|
export const SHIP_KIND_CARGO = '000023'
|
||||||
|
export const SHIP_KIND_TANKER = '000024'
|
||||||
|
export const SHIP_KIND_GOV = '000025'
|
||||||
|
export const SHIP_KIND_NORMAL = '000027'
|
||||||
|
export const SHIP_KIND_BUOY = '000028'
|
||||||
|
|
||||||
|
// --- 선종별 라벨 ---
|
||||||
|
export const SHIP_KIND_LABELS: Record<string, string> = {
|
||||||
|
[SHIP_KIND_FISHING]: '어선',
|
||||||
|
[SHIP_KIND_KCGV]: '경비함정',
|
||||||
|
[SHIP_KIND_PASSENGER]: '여객선',
|
||||||
|
[SHIP_KIND_CARGO]: '화물선',
|
||||||
|
[SHIP_KIND_TANKER]: '유조선',
|
||||||
|
[SHIP_KIND_GOV]: '관공선',
|
||||||
|
[SHIP_KIND_NORMAL]: '일반',
|
||||||
|
[SHIP_KIND_BUOY]: '부이',
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 선종별 CSS HEX 색상 (범례, UI 표시용) ---
|
||||||
|
export const SHIP_KIND_COLORS: Record<string, string> = {
|
||||||
|
[SHIP_KIND_FISHING]: '#00C853',
|
||||||
|
[SHIP_KIND_KCGV]: '#FF5722',
|
||||||
|
[SHIP_KIND_PASSENGER]: '#2196F3',
|
||||||
|
[SHIP_KIND_CARGO]: '#9C27B0',
|
||||||
|
[SHIP_KIND_TANKER]: '#F44336',
|
||||||
|
[SHIP_KIND_GOV]: '#FF9800',
|
||||||
|
[SHIP_KIND_NORMAL]: '#607D8B',
|
||||||
|
[SHIP_KIND_BUOY]: '#795548',
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 선종별 RGBA 색상 (Deck.gl 항적 레이어용) ---
|
||||||
|
export const SHIP_KIND_TRACK_RGBA: Record<string, [number, number, number, number]> = {
|
||||||
|
[SHIP_KIND_FISHING]: [25, 116, 25, 150],
|
||||||
|
[SHIP_KIND_KCGV]: [0, 41, 255, 150],
|
||||||
|
[SHIP_KIND_PASSENGER]: [176, 42, 42, 150],
|
||||||
|
[SHIP_KIND_CARGO]: [255, 139, 54, 150],
|
||||||
|
[SHIP_KIND_TANKER]: [255, 0, 0, 150],
|
||||||
|
[SHIP_KIND_GOV]: [92, 30, 224, 150],
|
||||||
|
[SHIP_KIND_NORMAL]: [255, 135, 207, 150],
|
||||||
|
[SHIP_KIND_BUOY]: [232, 95, 27, 150],
|
||||||
|
}
|
||||||
|
export const DEFAULT_TRACK_RGBA: [number, number, number, number] = [128, 128, 128, 150]
|
||||||
|
|
||||||
|
// --- 속도 임계값 ---
|
||||||
|
export const SPEED_THRESHOLD = 1 // knots (정박/운항 경계)
|
||||||
|
|
||||||
|
// --- 줌 레벨별 아이콘 크기 ---
|
||||||
|
export const ZOOM_ICON_SIZES: { maxZoom: number; size: number }[] = [
|
||||||
|
{ maxZoom: 8, size: 15 },
|
||||||
|
{ maxZoom: 11, size: 25 },
|
||||||
|
{ maxZoom: 14, size: 35 },
|
||||||
|
{ maxZoom: Infinity, size: 40 },
|
||||||
|
]
|
||||||
|
export const BUOY_ICON_SIZE = 16
|
||||||
|
export const STOPPED_ICON_SIZE = 8
|
||||||
|
|
||||||
|
// --- 아이콘 Atlas 스프라이트 매핑 ---
|
||||||
|
export interface IconAtlasEntry {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
anchorY?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ICON_ATLAS_MAPPING: Record<string, IconAtlasEntry> = {
|
||||||
|
// 이동 중 (화살표 형태)
|
||||||
|
fishingImg: { x: 1, y: 518, width: 16, height: 27 },
|
||||||
|
kcgvImg: { x: 45, y: 115, width: 17, height: 27 },
|
||||||
|
passImg: { x: 24, y: 486, width: 17, height: 27 },
|
||||||
|
cargoImg: { x: 44, y: 144, width: 17, height: 27 },
|
||||||
|
hazardImg: { x: 44, y: 173, width: 17, height: 27 },
|
||||||
|
govImg: { x: 43, y: 486, width: 17, height: 27 },
|
||||||
|
etcImg: { x: 24, y: 515, width: 17, height: 27 },
|
||||||
|
bouyImg: { x: 1, y: 485, width: 21, height: 31 },
|
||||||
|
|
||||||
|
// 정지 (원형)
|
||||||
|
fishingStopImg: { x: 51, y: 51, width: 8, height: 8 },
|
||||||
|
kcgvStopImg: { x: 51, y: 41, width: 8, height: 8 },
|
||||||
|
passStopImg: { x: 51, y: 21, width: 8, height: 8 },
|
||||||
|
cargoStopImg: { x: 51, y: 1, width: 8, height: 8 },
|
||||||
|
hazardStopImg: { x: 51, y: 11, width: 8, height: 8 },
|
||||||
|
govStopImg: { x: 51, y: 31, width: 8, height: 8 },
|
||||||
|
etcStopImg: { x: 51, y: 71, width: 8, height: 8 },
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 선종 → 아이콘 키 매핑 ---
|
||||||
|
export const ICON_KEY_MOVING: Record<string, string> = {
|
||||||
|
[SHIP_KIND_FISHING]: 'fishingImg',
|
||||||
|
[SHIP_KIND_KCGV]: 'kcgvImg',
|
||||||
|
[SHIP_KIND_PASSENGER]: 'passImg',
|
||||||
|
[SHIP_KIND_CARGO]: 'cargoImg',
|
||||||
|
[SHIP_KIND_TANKER]: 'hazardImg',
|
||||||
|
[SHIP_KIND_GOV]: 'govImg',
|
||||||
|
[SHIP_KIND_NORMAL]: 'etcImg',
|
||||||
|
[SHIP_KIND_BUOY]: 'bouyImg',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ICON_KEY_STOPPED: Record<string, string> = {
|
||||||
|
[SHIP_KIND_FISHING]: 'fishingStopImg',
|
||||||
|
[SHIP_KIND_KCGV]: 'kcgvStopImg',
|
||||||
|
[SHIP_KIND_PASSENGER]: 'passStopImg',
|
||||||
|
[SHIP_KIND_CARGO]: 'cargoStopImg',
|
||||||
|
[SHIP_KIND_TANKER]: 'hazardStopImg',
|
||||||
|
[SHIP_KIND_GOV]: 'govStopImg',
|
||||||
|
[SHIP_KIND_NORMAL]: 'etcStopImg',
|
||||||
|
[SHIP_KIND_BUOY]: 'bouyImg',
|
||||||
|
}
|
||||||
70
frontend/src/features/vessel-map/hooks/useMapInstance.ts
Normal file
70
frontend/src/features/vessel-map/hooks/useMapInstance.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
|
import type { Layer } from '@deck.gl/core'
|
||||||
|
import type maplibregl from 'maplibre-gl'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapLibre + Deck.gl MapboxOverlay 통합 관리 훅
|
||||||
|
*
|
||||||
|
* - MapboxOverlay 생성/제거 라이프사이클
|
||||||
|
* - setLayers()로 Deck.gl 레이어 동적 업데이트
|
||||||
|
* - pickObject()로 클릭/호버 인터랙션
|
||||||
|
*/
|
||||||
|
export function useMapInstance(map: maplibregl.Map | null) {
|
||||||
|
const overlayRef = useRef<MapboxOverlay | null>(null)
|
||||||
|
|
||||||
|
// Overlay 초기화 + 정리
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const initOverlay = () => {
|
||||||
|
if (overlayRef.current) return
|
||||||
|
|
||||||
|
const overlay = new MapboxOverlay({
|
||||||
|
interleaved: false,
|
||||||
|
layers: [],
|
||||||
|
})
|
||||||
|
map.addControl(overlay)
|
||||||
|
overlayRef.current = overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initOverlay()
|
||||||
|
} else {
|
||||||
|
map.on('load', initOverlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('load', initOverlay)
|
||||||
|
if (overlayRef.current) {
|
||||||
|
try {
|
||||||
|
map.removeControl(overlayRef.current)
|
||||||
|
} catch {
|
||||||
|
// 맵이 이미 제거된 경우
|
||||||
|
}
|
||||||
|
overlayRef.current.finalize()
|
||||||
|
overlayRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [map])
|
||||||
|
|
||||||
|
/** Deck.gl 레이어 배열 업데이트 */
|
||||||
|
const setLayers = useCallback((layers: Layer[]) => {
|
||||||
|
overlayRef.current?.setProps({ layers })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 특정 좌표의 객체 피킹 */
|
||||||
|
const pickObject = useCallback(
|
||||||
|
(x: number, y: number, layerIds?: string[]) => {
|
||||||
|
if (!overlayRef.current) return null
|
||||||
|
try {
|
||||||
|
return overlayRef.current.pickObject({ x, y, layerIds }) ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { overlayRef, setLayers, pickObject }
|
||||||
|
}
|
||||||
61
frontend/src/features/vessel-map/index.ts
Normal file
61
frontend/src/features/vessel-map/index.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
VesselPosition,
|
||||||
|
TrackSegment,
|
||||||
|
InterpolatedPosition,
|
||||||
|
ViewportBounds,
|
||||||
|
VesselIconData,
|
||||||
|
TrackPathData,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export {
|
||||||
|
SHIP_KIND_FISHING,
|
||||||
|
SHIP_KIND_KCGV,
|
||||||
|
SHIP_KIND_PASSENGER,
|
||||||
|
SHIP_KIND_CARGO,
|
||||||
|
SHIP_KIND_TANKER,
|
||||||
|
SHIP_KIND_GOV,
|
||||||
|
SHIP_KIND_NORMAL,
|
||||||
|
SHIP_KIND_BUOY,
|
||||||
|
SHIP_KIND_LABELS,
|
||||||
|
SHIP_KIND_COLORS,
|
||||||
|
SHIP_KIND_TRACK_RGBA,
|
||||||
|
DEFAULT_TRACK_RGBA,
|
||||||
|
SPEED_THRESHOLD,
|
||||||
|
ICON_ATLAS_MAPPING,
|
||||||
|
ICON_KEY_MOVING,
|
||||||
|
ICON_KEY_STOPPED,
|
||||||
|
} from './constants'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export {
|
||||||
|
getViewportBounds,
|
||||||
|
filterByViewport,
|
||||||
|
expandBounds,
|
||||||
|
calcPositionBounds,
|
||||||
|
} from './utils/viewport'
|
||||||
|
|
||||||
|
export {
|
||||||
|
getPositionsAtTime,
|
||||||
|
getTimeRange,
|
||||||
|
} from './utils/interpolation'
|
||||||
|
|
||||||
|
export {
|
||||||
|
getShipKindColor,
|
||||||
|
getShipKindLabel,
|
||||||
|
getTrackColor,
|
||||||
|
getIconKey,
|
||||||
|
getIconSize,
|
||||||
|
getAllShipKinds,
|
||||||
|
} from './utils/shipKindColors'
|
||||||
|
|
||||||
|
// Layers
|
||||||
|
export { createVesselIconLayer } from './layers/VesselIconLayer'
|
||||||
|
export type { VesselIconLayerProps } from './layers/VesselIconLayer'
|
||||||
|
|
||||||
|
export { createTrackPathLayer } from './layers/TrackPathLayer'
|
||||||
|
export type { TrackPathLayerProps } from './layers/TrackPathLayer'
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export { useMapInstance } from './hooks/useMapInstance'
|
||||||
42
frontend/src/features/vessel-map/layers/TrackPathLayer.ts
Normal file
42
frontend/src/features/vessel-map/layers/TrackPathLayer.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { PathLayer } from '@deck.gl/layers'
|
||||||
|
import type { TrackPathData } from '../types'
|
||||||
|
|
||||||
|
export interface TrackPathLayerProps {
|
||||||
|
id?: string
|
||||||
|
data: TrackPathData[]
|
||||||
|
visible?: boolean
|
||||||
|
widthMinPixels?: number
|
||||||
|
pickable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항적 경로 레이어 팩토리
|
||||||
|
* 선종별 RGBA 색상 적용, 줌에 따라 폭 자동 조절
|
||||||
|
*/
|
||||||
|
export function createTrackPathLayer({
|
||||||
|
id = 'track-path',
|
||||||
|
data,
|
||||||
|
visible = true,
|
||||||
|
widthMinPixels = 2,
|
||||||
|
pickable = false,
|
||||||
|
}: TrackPathLayerProps): PathLayer<TrackPathData> {
|
||||||
|
return new PathLayer<TrackPathData>({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
visible,
|
||||||
|
pickable,
|
||||||
|
getPath: (d) => d.path,
|
||||||
|
getColor: (d) => d.color,
|
||||||
|
getWidth: 3,
|
||||||
|
widthMinPixels,
|
||||||
|
widthMaxPixels: 6,
|
||||||
|
widthUnits: 'pixels' as const,
|
||||||
|
jointRounded: true,
|
||||||
|
capRounded: true,
|
||||||
|
billboard: false,
|
||||||
|
updateTriggers: {
|
||||||
|
getPath: [data],
|
||||||
|
getColor: [data],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
48
frontend/src/features/vessel-map/layers/VesselIconLayer.ts
Normal file
48
frontend/src/features/vessel-map/layers/VesselIconLayer.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { IconLayer } from '@deck.gl/layers'
|
||||||
|
import type { VesselIconData } from '../types'
|
||||||
|
import { ICON_ATLAS_MAPPING } from '../constants'
|
||||||
|
import atlasUrl from '../assets/atlas.png'
|
||||||
|
|
||||||
|
export interface VesselIconLayerProps {
|
||||||
|
id?: string
|
||||||
|
data: VesselIconData[]
|
||||||
|
visible?: boolean
|
||||||
|
pickable?: boolean
|
||||||
|
onClick?: (info: { object?: VesselIconData }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 아이콘 레이어 팩토리
|
||||||
|
* Atlas 스프라이트 기반 — SOG에 따라 화살표/원형 아이콘, COG 회전
|
||||||
|
*/
|
||||||
|
export function createVesselIconLayer({
|
||||||
|
id = 'vessel-icon',
|
||||||
|
data,
|
||||||
|
visible = true,
|
||||||
|
pickable = true,
|
||||||
|
onClick,
|
||||||
|
}: VesselIconLayerProps): IconLayer<VesselIconData> {
|
||||||
|
return new IconLayer<VesselIconData>({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
visible,
|
||||||
|
pickable,
|
||||||
|
iconAtlas: atlasUrl,
|
||||||
|
iconMapping: ICON_ATLAS_MAPPING,
|
||||||
|
getIcon: (d) => d.icon,
|
||||||
|
getPosition: (d) => d.position,
|
||||||
|
getSize: (d) => d.size,
|
||||||
|
getAngle: (d) => -d.angle, // Deck.gl: 반시계 양수 → COG 시계방향 보정
|
||||||
|
sizeScale: 1,
|
||||||
|
sizeUnits: 'pixels' as const,
|
||||||
|
sizeMinPixels: 4,
|
||||||
|
billboard: false,
|
||||||
|
alphaCutoff: 0.05,
|
||||||
|
onClick: onClick as never,
|
||||||
|
updateTriggers: {
|
||||||
|
getIcon: [data],
|
||||||
|
getSize: [data],
|
||||||
|
getAngle: [data],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
75
frontend/src/features/vessel-map/types.ts
Normal file
75
frontend/src/features/vessel-map/types.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* vessel-map 공유 타입 정의
|
||||||
|
* 최근위치 / 선박항적 / 뷰포트리플레이 공통으로 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 선박 위치 (최근위치 API 응답 → 내부 표현) */
|
||||||
|
export interface VesselPosition {
|
||||||
|
mmsi: string
|
||||||
|
imo?: number
|
||||||
|
lon: number
|
||||||
|
lat: number
|
||||||
|
sog: number
|
||||||
|
cog: number
|
||||||
|
shipNm?: string
|
||||||
|
shipTy?: string
|
||||||
|
shipKindCode?: string
|
||||||
|
nationalCode?: string
|
||||||
|
lastUpdate: string
|
||||||
|
shipImagePath?: string | null
|
||||||
|
shipImageCount?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 항적 세그먼트 (API 응답 → 내부 처리용) */
|
||||||
|
export interface TrackSegment {
|
||||||
|
vesselId: string
|
||||||
|
nationalCode?: string
|
||||||
|
shipKindCode?: string
|
||||||
|
shipName?: string
|
||||||
|
geometry: [number, number][] // [lon, lat][]
|
||||||
|
timestampsMs: number[] // Unix epoch ms
|
||||||
|
speeds: number[]
|
||||||
|
totalDistance?: number
|
||||||
|
avgSpeed?: number
|
||||||
|
maxSpeed?: number
|
||||||
|
pointCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보간된 현재 위치 (타임라인 스크럽 / 애니메이션용) */
|
||||||
|
export interface InterpolatedPosition {
|
||||||
|
vesselId: string
|
||||||
|
lon: number
|
||||||
|
lat: number
|
||||||
|
heading: number
|
||||||
|
speed: number
|
||||||
|
shipName: string
|
||||||
|
shipKindCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 맵 뷰포트 바운드 */
|
||||||
|
export interface ViewportBounds {
|
||||||
|
west: number
|
||||||
|
south: number
|
||||||
|
east: number
|
||||||
|
north: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deck.gl IconLayer 데이터 포인트 */
|
||||||
|
export interface VesselIconData {
|
||||||
|
mmsi: string
|
||||||
|
position: [number, number] // [lon, lat]
|
||||||
|
angle: number // COG (rotation)
|
||||||
|
icon: string // atlas mapping key
|
||||||
|
size: number
|
||||||
|
shipNm?: string
|
||||||
|
shipKindCode?: string
|
||||||
|
sog?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deck.gl PathLayer 데이터 포인트 */
|
||||||
|
export interface TrackPathData {
|
||||||
|
vesselId: string
|
||||||
|
path: [number, number][] // [lon, lat][]
|
||||||
|
color: [number, number, number, number]
|
||||||
|
shipKindCode?: string
|
||||||
|
}
|
||||||
121
frontend/src/features/vessel-map/utils/interpolation.ts
Normal file
121
frontend/src/features/vessel-map/utils/interpolation.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import type { TrackSegment, InterpolatedPosition } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이진 탐색으로 currentTime이 속하는 구간 [lo, hi] 인덱스 반환
|
||||||
|
* O(log n) — 프레임당 선박 수 × O(log n)
|
||||||
|
*/
|
||||||
|
function findTimeIndex(timestampsMs: number[], currentTime: number): [number, number] {
|
||||||
|
let lo = 0
|
||||||
|
let hi = timestampsMs.length - 1
|
||||||
|
|
||||||
|
while (lo < hi - 1) {
|
||||||
|
const mid = (lo + hi) >> 1
|
||||||
|
if (timestampsMs[mid] <= currentTime) lo = mid
|
||||||
|
else hi = mid
|
||||||
|
}
|
||||||
|
|
||||||
|
return [lo, hi]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 두 점 사이 방위각 계산 (degrees, 0-360) */
|
||||||
|
function calculateHeading(
|
||||||
|
lon1: number, lat1: number,
|
||||||
|
lon2: number, lat2: number,
|
||||||
|
): number {
|
||||||
|
const dLon = ((lon2 - lon1) * Math.PI) / 180
|
||||||
|
const lat1Rad = (lat1 * Math.PI) / 180
|
||||||
|
const lat2Rad = (lat2 * Math.PI) / 180
|
||||||
|
const y = Math.sin(dLon) * Math.cos(lat2Rad)
|
||||||
|
const x =
|
||||||
|
Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
||||||
|
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon)
|
||||||
|
let heading = (Math.atan2(y, x) * 180) / Math.PI
|
||||||
|
if (heading < 0) heading += 360
|
||||||
|
return heading
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트랙 배열에서 currentTime 기준 보간된 위치 목록 생성
|
||||||
|
* disabledIds에 포함된 vesselId는 제외
|
||||||
|
*/
|
||||||
|
export function getPositionsAtTime(
|
||||||
|
tracks: TrackSegment[],
|
||||||
|
currentTime: number,
|
||||||
|
disabledIds?: Set<string>,
|
||||||
|
): InterpolatedPosition[] {
|
||||||
|
const positions: InterpolatedPosition[] = []
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (disabledIds?.has(track.vesselId)) continue
|
||||||
|
|
||||||
|
const { timestampsMs, geometry, speeds } = track
|
||||||
|
if (timestampsMs.length === 0) continue
|
||||||
|
|
||||||
|
// 경계값 처리
|
||||||
|
if (currentTime <= timestampsMs[0]) {
|
||||||
|
positions.push({
|
||||||
|
vesselId: track.vesselId,
|
||||||
|
lon: geometry[0][0],
|
||||||
|
lat: geometry[0][1],
|
||||||
|
heading: 0,
|
||||||
|
speed: speeds[0] || 0,
|
||||||
|
shipName: track.shipName || '',
|
||||||
|
shipKindCode: track.shipKindCode || '',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTime >= timestampsMs[timestampsMs.length - 1]) {
|
||||||
|
const last = geometry.length - 1
|
||||||
|
positions.push({
|
||||||
|
vesselId: track.vesselId,
|
||||||
|
lon: geometry[last][0],
|
||||||
|
lat: geometry[last][1],
|
||||||
|
heading: 0,
|
||||||
|
speed: speeds[last] || 0,
|
||||||
|
shipName: track.shipName || '',
|
||||||
|
shipKindCode: track.shipKindCode || '',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이진 탐색 + 선형 보간
|
||||||
|
const [lo, hi] = findTimeIndex(timestampsMs, currentTime)
|
||||||
|
const t1 = timestampsMs[lo]
|
||||||
|
const t2 = timestampsMs[hi]
|
||||||
|
const ratio = t2 === t1 ? 0 : (currentTime - t1) / (t2 - t1)
|
||||||
|
|
||||||
|
const p1 = geometry[lo]
|
||||||
|
const p2 = geometry[hi]
|
||||||
|
const lon = p1[0] + (p2[0] - p1[0]) * ratio
|
||||||
|
const lat = p1[1] + (p2[1] - p1[1]) * ratio
|
||||||
|
const heading = calculateHeading(p1[0], p1[1], p2[0], p2[1])
|
||||||
|
const speed = speeds[lo] + (speeds[hi] - speeds[lo]) * ratio
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
vesselId: track.vesselId,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
heading,
|
||||||
|
speed,
|
||||||
|
shipName: track.shipName || '',
|
||||||
|
shipKindCode: track.shipKindCode || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 트랙 배열의 전체 시간 범위 [min, max] (ms) */
|
||||||
|
export function getTimeRange(tracks: TrackSegment[]): [number, number] {
|
||||||
|
let min = Infinity
|
||||||
|
let max = -Infinity
|
||||||
|
for (const t of tracks) {
|
||||||
|
if (t.timestampsMs.length === 0) continue
|
||||||
|
const first = t.timestampsMs[0]
|
||||||
|
const last = t.timestampsMs[t.timestampsMs.length - 1]
|
||||||
|
if (first < min) min = first
|
||||||
|
if (last > max) max = last
|
||||||
|
}
|
||||||
|
return [min, max]
|
||||||
|
}
|
||||||
55
frontend/src/features/vessel-map/utils/shipKindColors.ts
Normal file
55
frontend/src/features/vessel-map/utils/shipKindColors.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
SHIP_KIND_COLORS,
|
||||||
|
SHIP_KIND_LABELS,
|
||||||
|
SHIP_KIND_TRACK_RGBA,
|
||||||
|
DEFAULT_TRACK_RGBA,
|
||||||
|
ICON_KEY_MOVING,
|
||||||
|
ICON_KEY_STOPPED,
|
||||||
|
SPEED_THRESHOLD,
|
||||||
|
ZOOM_ICON_SIZES,
|
||||||
|
BUOY_ICON_SIZE,
|
||||||
|
STOPPED_ICON_SIZE,
|
||||||
|
SHIP_KIND_BUOY,
|
||||||
|
} from '../constants'
|
||||||
|
|
||||||
|
/** 선종 → HEX 색상 (UI/범례용) */
|
||||||
|
export function getShipKindColor(shipKindCode?: string): string {
|
||||||
|
return (shipKindCode && SHIP_KIND_COLORS[shipKindCode]) || '#607D8B'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 선종 → 라벨 */
|
||||||
|
export function getShipKindLabel(shipKindCode?: string): string {
|
||||||
|
return (shipKindCode && SHIP_KIND_LABELS[shipKindCode]) || '기타'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 선종 → RGBA 색상 (Deck.gl 항적용) */
|
||||||
|
export function getTrackColor(shipKindCode?: string): [number, number, number, number] {
|
||||||
|
return (shipKindCode && SHIP_KIND_TRACK_RGBA[shipKindCode]) || DEFAULT_TRACK_RGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SOG + 선종 → 아이콘 atlas key */
|
||||||
|
export function getIconKey(shipKindCode: string | undefined, sog: number): string {
|
||||||
|
const isMoving = sog > SPEED_THRESHOLD
|
||||||
|
const mapping = isMoving ? ICON_KEY_MOVING : ICON_KEY_STOPPED
|
||||||
|
return (shipKindCode && mapping[shipKindCode]) || (isMoving ? 'etcImg' : 'etcStopImg')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 줌 + SOG + 선종 → 아이콘 크기 (px) */
|
||||||
|
export function getIconSize(zoom: number, shipKindCode: string | undefined, sog: number): number {
|
||||||
|
if (shipKindCode === SHIP_KIND_BUOY) return BUOY_ICON_SIZE
|
||||||
|
if (sog <= SPEED_THRESHOLD) return STOPPED_ICON_SIZE
|
||||||
|
|
||||||
|
for (const entry of ZOOM_ICON_SIZES) {
|
||||||
|
if (zoom < entry.maxZoom) return entry.size
|
||||||
|
}
|
||||||
|
return ZOOM_ICON_SIZES[ZOOM_ICON_SIZES.length - 1].size
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모든 선종 코드와 라벨/색상 목록 (범례 렌더링용) */
|
||||||
|
export function getAllShipKinds(): { code: string; label: string; color: string }[] {
|
||||||
|
return Object.entries(SHIP_KIND_LABELS).map(([code, label]) => ({
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
color: SHIP_KIND_COLORS[code] || '#607D8B',
|
||||||
|
}))
|
||||||
|
}
|
||||||
52
frontend/src/features/vessel-map/utils/viewport.ts
Normal file
52
frontend/src/features/vessel-map/utils/viewport.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type maplibregl from 'maplibre-gl'
|
||||||
|
import type { ViewportBounds, VesselPosition } from '../types'
|
||||||
|
|
||||||
|
/** 맵 인스턴스에서 현재 뷰포트 바운드 추출 */
|
||||||
|
export function getViewportBounds(map: maplibregl.Map): ViewportBounds {
|
||||||
|
const bounds = map.getBounds()
|
||||||
|
return {
|
||||||
|
west: bounds.getWest(),
|
||||||
|
south: bounds.getSouth(),
|
||||||
|
east: bounds.getEast(),
|
||||||
|
north: bounds.getNorth(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 바운드 내부에 있는 선박만 필터링 */
|
||||||
|
export function filterByViewport<T extends { lon: number; lat: number }>(
|
||||||
|
positions: T[],
|
||||||
|
bounds: ViewportBounds,
|
||||||
|
): T[] {
|
||||||
|
return positions.filter(
|
||||||
|
(p) =>
|
||||||
|
p.lon >= bounds.west &&
|
||||||
|
p.lon <= bounds.east &&
|
||||||
|
p.lat >= bounds.south &&
|
||||||
|
p.lat <= bounds.north,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 바운드를 마진(비율)만큼 확장 */
|
||||||
|
export function expandBounds(bounds: ViewportBounds, margin = 0.1): ViewportBounds {
|
||||||
|
const lonSpan = bounds.east - bounds.west
|
||||||
|
const latSpan = bounds.north - bounds.south
|
||||||
|
return {
|
||||||
|
west: bounds.west - lonSpan * margin,
|
||||||
|
south: bounds.south - latSpan * margin,
|
||||||
|
east: bounds.east + lonSpan * margin,
|
||||||
|
north: bounds.north + latSpan * margin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VesselPosition 배열의 전체 바운드 계산 (fitBounds용) */
|
||||||
|
export function calcPositionBounds(positions: VesselPosition[]): ViewportBounds | null {
|
||||||
|
if (positions.length === 0) return null
|
||||||
|
let west = Infinity, south = Infinity, east = -Infinity, north = -Infinity
|
||||||
|
for (const p of positions) {
|
||||||
|
if (p.lon < west) west = p.lon
|
||||||
|
if (p.lon > east) east = p.lon
|
||||||
|
if (p.lat < south) south = p.lat
|
||||||
|
if (p.lat > north) north = p.lat
|
||||||
|
}
|
||||||
|
return { west, south, east, north }
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import { useTrackStore } from '../stores/trackStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 맵 하단 오버레이: 타임라인 슬라이더 + 항적 통계
|
||||||
|
*/
|
||||||
|
export default function TrackInfoPanel() {
|
||||||
|
const tracks = useTrackStore((s) => s.tracks)
|
||||||
|
const currentTime = useTrackStore((s) => s.currentTime)
|
||||||
|
const timeRange = useTrackStore((s) => s.timeRange)
|
||||||
|
const setProgressByRatio = useTrackStore((s) => s.setProgressByRatio)
|
||||||
|
|
||||||
|
if (tracks.length === 0) return null
|
||||||
|
|
||||||
|
const [min, max] = timeRange
|
||||||
|
const duration = max - min
|
||||||
|
const progress = duration > 0 ? (currentTime - min) / 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDistance = tracks.reduce((sum, t) => sum + (t.totalDistance || 0), 0)
|
||||||
|
const maxSpeed = Math.max(...tracks.map((t) => t.maxSpeed || 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-4 left-1/2 z-10 w-[480px] -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(min)}</span>
|
||||||
|
<span className="font-medium text-foreground">{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(max)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 요약 */}
|
||||||
|
<div className="flex justify-between text-xs text-muted">
|
||||||
|
<span>{tracks.length}척</span>
|
||||||
|
<span>총 거리: {totalDistance.toFixed(1)} nm</span>
|
||||||
|
<span>최고속도: {maxSpeed.toFixed(1)} kn</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useVesselTracks } from '../hooks/useVesselTracks'
|
||||||
|
import { useTrackStore } from '../stores/trackStore'
|
||||||
|
|
||||||
|
interface TrackQueryPanelProps {
|
||||||
|
/** 최근위치에서 클릭하여 넘어온 MMSI */
|
||||||
|
initialMmsi?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기본 시간 범위: 지금부터 24시간 전 */
|
||||||
|
function getDefaultTimeRange(): { start: string; end: string } {
|
||||||
|
const now = new Date()
|
||||||
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
return {
|
||||||
|
start: toLocalISOString(yesterday),
|
||||||
|
end: toLocalISOString(now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalISOString(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrackQueryPanel({ initialMmsi }: TrackQueryPanelProps) {
|
||||||
|
const { start, end } = getDefaultTimeRange()
|
||||||
|
const [mmsiInput, setMmsiInput] = useState(initialMmsi || '')
|
||||||
|
const [startTime, setStartTime] = useState(start)
|
||||||
|
const [endTime, setEndTime] = useState(end)
|
||||||
|
|
||||||
|
const { fetchTracks, clearTracks } = useVesselTracks()
|
||||||
|
const loading = useTrackStore((s) => s.loading)
|
||||||
|
const tracks = useTrackStore((s) => s.tracks)
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
const mmsiList = mmsiInput
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (mmsiList.length === 0) return
|
||||||
|
|
||||||
|
const startISO = startTime.replace('T', ' ') + ':00'
|
||||||
|
const endISO = endTime.replace('T', ' ') + ':00'
|
||||||
|
fetchTracks(mmsiList, startISO, endISO)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* MMSI 입력 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">
|
||||||
|
MMSI (쉼표로 구분)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mmsiInput}
|
||||||
|
onChange={(e) => setMmsiInput(e.target.value)}
|
||||||
|
placeholder="440113620, 441027000"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-foreground placeholder:text-muted focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시간 범위 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">시작</label>
|
||||||
|
<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 focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">종료</label>
|
||||||
|
<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 focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleQuery}
|
||||||
|
disabled={loading || !mmsiInput.trim()}
|
||||||
|
className="flex-1 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-primary/90 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
{loading ? '조회 중...' : '항적 조회'}
|
||||||
|
</button>
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearTracks}
|
||||||
|
className="rounded-md border border-border px-3 py-1.5 text-xs text-muted hover:bg-surface-hover transition"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 요약 */}
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-border p-2 text-xs text-muted">
|
||||||
|
<div className="font-medium text-foreground">{tracks.length}척 항적 로드</div>
|
||||||
|
{tracks.map((t) => (
|
||||||
|
<div key={t.vesselId} className="mt-1 flex justify-between">
|
||||||
|
<span>{t.shipName || t.vesselId}</span>
|
||||||
|
<span>{t.geometry.length} pts</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { Layer } from '@deck.gl/core'
|
||||||
|
import { useTrackStore } from '../stores/trackStore'
|
||||||
|
import { createTrackPathLayer } from '../../vessel-map/layers/TrackPathLayer'
|
||||||
|
import { createVesselIconLayer } from '../../vessel-map/layers/VesselIconLayer'
|
||||||
|
import { getTrackColor, getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors'
|
||||||
|
import type { TrackPathData, VesselIconData } from '../../vessel-map'
|
||||||
|
|
||||||
|
interface VesselTracksLayerProps {
|
||||||
|
zoom: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 항적 Deck.gl 레이어 생성 훅
|
||||||
|
* PathLayer(경로) + IconLayer(현재 보간 위치)
|
||||||
|
*/
|
||||||
|
export function useVesselTracksLayer({ zoom }: VesselTracksLayerProps): Layer[] {
|
||||||
|
const tracks = useTrackStore((s) => s.tracks)
|
||||||
|
const disabledVesselIds = useTrackStore((s) => s.disabledVesselIds)
|
||||||
|
const getCurrentPositions = useTrackStore((s) => s.getCurrentPositions)
|
||||||
|
const currentTime = useTrackStore((s) => s.currentTime)
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (tracks.length === 0) return []
|
||||||
|
|
||||||
|
// 1. 항적 경로 레이어
|
||||||
|
const pathData: TrackPathData[] = tracks
|
||||||
|
.filter((t) => !disabledVesselIds.has(t.vesselId))
|
||||||
|
.map((t) => ({
|
||||||
|
vesselId: t.vesselId,
|
||||||
|
path: t.geometry,
|
||||||
|
color: getTrackColor(t.shipKindCode),
|
||||||
|
shipKindCode: t.shipKindCode,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 2. 현재 위치 아이콘 레이어
|
||||||
|
const positions = getCurrentPositions()
|
||||||
|
const iconData: VesselIconData[] = positions.map((p) => ({
|
||||||
|
mmsi: p.vesselId,
|
||||||
|
position: [p.lon, p.lat],
|
||||||
|
angle: p.heading,
|
||||||
|
icon: getIconKey(p.shipKindCode, p.speed),
|
||||||
|
size: getIconSize(zoom, p.shipKindCode, p.speed),
|
||||||
|
shipNm: p.shipName,
|
||||||
|
shipKindCode: p.shipKindCode,
|
||||||
|
sog: p.speed,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [
|
||||||
|
createTrackPathLayer({ id: 'vessel-track-path', data: pathData }),
|
||||||
|
createVesselIconLayer({ id: 'vessel-track-icon', data: iconData, pickable: false }),
|
||||||
|
]
|
||||||
|
}, [tracks, disabledVesselIds, currentTime, zoom, getCurrentPositions])
|
||||||
|
}
|
||||||
52
frontend/src/features/vessel-tracks/hooks/useVesselTracks.ts
Normal file
52
frontend/src/features/vessel-tracks/hooks/useVesselTracks.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { gisApi, type VesselTrackResult } from '../../../api/gisApi'
|
||||||
|
import { useTrackStore } from '../stores/trackStore'
|
||||||
|
import type { TrackSegment } from '../../vessel-map'
|
||||||
|
|
||||||
|
/** API 응답 → 내부 TrackSegment 변환 */
|
||||||
|
function toTrackSegment(r: VesselTrackResult): TrackSegment {
|
||||||
|
return {
|
||||||
|
vesselId: r.vesselId,
|
||||||
|
nationalCode: r.nationalCode,
|
||||||
|
shipKindCode: r.shipKindCode,
|
||||||
|
shipName: r.shipName,
|
||||||
|
geometry: r.geometry as [number, number][],
|
||||||
|
// 백엔드가 문자열 배열로 전송 → 숫자 ms 변환
|
||||||
|
timestampsMs: r.timestamps.map((t) => {
|
||||||
|
const n = Number(t)
|
||||||
|
// 초 단위(10자리)면 ×1000, ms 단위(13자리)면 그대로
|
||||||
|
return n < 1e12 ? n * 1000 : n
|
||||||
|
}),
|
||||||
|
speeds: r.speeds,
|
||||||
|
totalDistance: r.totalDistance,
|
||||||
|
avgSpeed: r.avgSpeed,
|
||||||
|
maxSpeed: r.maxSpeed,
|
||||||
|
pointCount: r.pointCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 선박 항적 조회 훅 */
|
||||||
|
export function useVesselTracks() {
|
||||||
|
const setTracks = useTrackStore((s) => s.setTracks)
|
||||||
|
const setLoading = useTrackStore((s) => s.setLoading)
|
||||||
|
const clearTracks = useTrackStore((s) => s.clearTracks)
|
||||||
|
|
||||||
|
const fetchTracks = useCallback(
|
||||||
|
async (mmsiList: string[], startTime: string, endTime: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await gisApi.getVesselTracks(mmsiList, startTime, endTime)
|
||||||
|
const segments = data.map(toTrackSegment)
|
||||||
|
setTracks(segments)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useVesselTracks] fetch failed:', err)
|
||||||
|
clearTracks()
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setTracks, setLoading, clearTracks],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { fetchTracks, clearTracks }
|
||||||
|
}
|
||||||
5
frontend/src/features/vessel-tracks/index.ts
Normal file
5
frontend/src/features/vessel-tracks/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { useTrackStore } from './stores/trackStore'
|
||||||
|
export { useVesselTracks } from './hooks/useVesselTracks'
|
||||||
|
export { useVesselTracksLayer } from './components/VesselTracksLayer'
|
||||||
|
export { default as TrackQueryPanel } from './components/TrackQueryPanel'
|
||||||
|
export { default as TrackInfoPanel } from './components/TrackInfoPanel'
|
||||||
89
frontend/src/features/vessel-tracks/stores/trackStore.ts
Normal file
89
frontend/src/features/vessel-tracks/stores/trackStore.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { TrackSegment, InterpolatedPosition } from '../../vessel-map'
|
||||||
|
import { getPositionsAtTime, getTimeRange } from '../../vessel-map/utils/interpolation'
|
||||||
|
|
||||||
|
interface TrackState {
|
||||||
|
/** 현재 로드된 트랙 데이터 */
|
||||||
|
tracks: TrackSegment[]
|
||||||
|
|
||||||
|
/** 타임라인 현재 시간 (ms) */
|
||||||
|
currentTime: number
|
||||||
|
|
||||||
|
/** 시간 범위 [min, max] (ms) */
|
||||||
|
timeRange: [number, number]
|
||||||
|
|
||||||
|
/** 비활성화된 선박 ID (항적 숨김) */
|
||||||
|
disabledVesselIds: Set<string>
|
||||||
|
|
||||||
|
/** 로딩 상태 */
|
||||||
|
loading: boolean
|
||||||
|
|
||||||
|
/** 트랙 데이터 설정 */
|
||||||
|
setTracks: (tracks: TrackSegment[]) => void
|
||||||
|
|
||||||
|
/** 트랙 초기화 */
|
||||||
|
clearTracks: () => void
|
||||||
|
|
||||||
|
/** 현재 시간 설정 */
|
||||||
|
setCurrentTime: (time: number) => void
|
||||||
|
|
||||||
|
/** 비율(0~1)로 현재 시간 설정 (타임라인 드래그) */
|
||||||
|
setProgressByRatio: (ratio: number) => void
|
||||||
|
|
||||||
|
/** 선박 ID 토글 (항적 표시/숨김) */
|
||||||
|
toggleVesselVisibility: (vesselId: string) => void
|
||||||
|
|
||||||
|
/** 현재 시간 기준 보간된 위치 목록 */
|
||||||
|
getCurrentPositions: () => InterpolatedPosition[]
|
||||||
|
|
||||||
|
/** 로딩 상태 설정 */
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTrackStore = create<TrackState>((set, get) => ({
|
||||||
|
tracks: [],
|
||||||
|
currentTime: 0,
|
||||||
|
timeRange: [0, 0],
|
||||||
|
disabledVesselIds: new Set(),
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
setTracks: (tracks) => {
|
||||||
|
const range = getTimeRange(tracks)
|
||||||
|
set({
|
||||||
|
tracks,
|
||||||
|
timeRange: range,
|
||||||
|
currentTime: range[0],
|
||||||
|
disabledVesselIds: new Set(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTracks: () =>
|
||||||
|
set({
|
||||||
|
tracks: [],
|
||||||
|
currentTime: 0,
|
||||||
|
timeRange: [0, 0],
|
||||||
|
disabledVesselIds: new Set(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
setCurrentTime: (time) => set({ currentTime: time }),
|
||||||
|
|
||||||
|
setProgressByRatio: (ratio) => {
|
||||||
|
const [min, max] = get().timeRange
|
||||||
|
set({ currentTime: min + (max - min) * Math.max(0, Math.min(1, ratio)) })
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleVesselVisibility: (vesselId) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.disabledVesselIds)
|
||||||
|
if (next.has(vesselId)) next.delete(vesselId)
|
||||||
|
else next.add(vesselId)
|
||||||
|
return { disabledVesselIds: next }
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCurrentPositions: () => {
|
||||||
|
const { tracks, currentTime, disabledVesselIds } = get()
|
||||||
|
return getPositionsAtTime(tracks, currentTime, disabledVesselIds)
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
}))
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { useAnimationStore, PLAYBACK_SPEEDS } from '../hooks/useReplayAnimation'
|
||||||
|
|
||||||
|
export default function ReplayControlPanel() {
|
||||||
|
const isPlaying = useAnimationStore((s) => s.isPlaying)
|
||||||
|
const currentTime = useAnimationStore((s) => s.currentTime)
|
||||||
|
const startTime = useAnimationStore((s) => s.startTime)
|
||||||
|
const endTime = useAnimationStore((s) => s.endTime)
|
||||||
|
const playbackSpeed = useAnimationStore((s) => s.playbackSpeed)
|
||||||
|
const loop = useAnimationStore((s) => s.loop)
|
||||||
|
const play = useAnimationStore((s) => s.play)
|
||||||
|
const pause = useAnimationStore((s) => s.pause)
|
||||||
|
const stop = useAnimationStore((s) => s.stop)
|
||||||
|
const setPlaybackSpeed = useAnimationStore((s) => s.setPlaybackSpeed)
|
||||||
|
const setProgressByRatio = useAnimationStore((s) => s.setProgressByRatio)
|
||||||
|
const toggleLoop = useAnimationStore((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>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
frontend/src/features/viewport-replay/components/ReplayLayer.tsx
Normal file
125
frontend/src/features/viewport-replay/components/ReplayLayer.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { useMemo, useRef, useEffect } from 'react'
|
||||||
|
import type { Layer } from '@deck.gl/core'
|
||||||
|
import { TripsLayer } from '@deck.gl/geo-layers'
|
||||||
|
import { useAnimationStore } from '../hooks/useReplayAnimation'
|
||||||
|
import { useMergedTrackStore } from '../stores/mergedTrackStore'
|
||||||
|
import { useReplayStore } from '../stores/replayStore'
|
||||||
|
import { createVesselIconLayer } from '../../vessel-map/layers/VesselIconLayer'
|
||||||
|
import { getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors'
|
||||||
|
import type { 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 ReplayLayerProps {
|
||||||
|
zoom: number
|
||||||
|
onLayersUpdate: (layers: Layer[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리플레이 레이어 관리 훅
|
||||||
|
* TripsLayer(항적 트레일) + IconLayer(현재 위치) 조합
|
||||||
|
* React 렌더 바이패스: zustand subscribe로 직접 레이어 업데이트
|
||||||
|
*/
|
||||||
|
export function useReplayLayer({ zoom, onLayersUpdate }: ReplayLayerProps) {
|
||||||
|
const queryCompleted = useReplayStore((s) => s.queryCompleted)
|
||||||
|
const vesselChunks = useMergedTrackStore((s) => s.vesselChunks)
|
||||||
|
const tripsDataRef = useRef<TripsData[]>([])
|
||||||
|
const lastRenderTimeRef = useRef(0)
|
||||||
|
|
||||||
|
// TripsData 구축 (쿼리 완료 또는 청크 수신 시)
|
||||||
|
useMemo(() => {
|
||||||
|
const paths = useMergedTrackStore.getState().getAllMergedPaths()
|
||||||
|
if (paths.length === 0) {
|
||||||
|
tripsDataRef.current = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Math.min(...paths.map((p) => p.timestampsMs[0]))
|
||||||
|
|
||||||
|
tripsDataRef.current = paths.map((p) => ({
|
||||||
|
vesselId: p.vesselId,
|
||||||
|
shipKindCode: p.shipKindCode || '000027',
|
||||||
|
path: p.geometry,
|
||||||
|
timestamps: p.timestampsMs.map((t) => t - startTime),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 애니메이션 시간 범위 초기화
|
||||||
|
useAnimationStore.getState().initTimeRange()
|
||||||
|
}, [vesselChunks])
|
||||||
|
|
||||||
|
// 렌더링 루프 (zustand subscribe → React 바이패스)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queryCompleted || tripsDataRef.current.length === 0) return
|
||||||
|
|
||||||
|
const renderFrame = () => {
|
||||||
|
const { currentTime, startTime } = useAnimationStore.getState()
|
||||||
|
const relativeTime = currentTime - startTime
|
||||||
|
|
||||||
|
const positions = useAnimationStore.getState().getCurrentVesselPositions()
|
||||||
|
const iconData: VesselIconData[] = positions.map((p) => ({
|
||||||
|
mmsi: p.vesselId,
|
||||||
|
position: [p.lon, p.lat],
|
||||||
|
angle: p.heading,
|
||||||
|
icon: getIconKey(p.shipKindCode, p.speed),
|
||||||
|
size: getIconSize(zoom, p.shipKindCode, p.speed),
|
||||||
|
shipNm: p.shipName,
|
||||||
|
shipKindCode: p.shipKindCode,
|
||||||
|
sog: p.speed,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const layers: Layer[] = [
|
||||||
|
new TripsLayer<TripsData>({
|
||||||
|
id: 'replay-trips-trail',
|
||||||
|
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: 'replay-vessel-icon',
|
||||||
|
data: iconData,
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
onLayersUpdate(layers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기 렌더
|
||||||
|
renderFrame()
|
||||||
|
|
||||||
|
// currentTime 구독 (Zustand v5: selector 없이 전체 상태 구독)
|
||||||
|
let prevTime = useAnimationStore.getState().currentTime
|
||||||
|
const unsub = useAnimationStore.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])
|
||||||
|
}
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useReplayStore } from '../stores/replayStore'
|
||||||
|
import { replayWebSocket } from '../services/replayWebSocket'
|
||||||
|
import type maplibregl from 'maplibre-gl'
|
||||||
|
import { getViewportBounds } from '../../vessel-map/utils/viewport'
|
||||||
|
|
||||||
|
interface ReplaySetupPanelProps {
|
||||||
|
map: maplibregl.Map | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultTimeRange(): { start: string; end: string } {
|
||||||
|
const now = new Date()
|
||||||
|
const hoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const fmt = (d: Date) =>
|
||||||
|
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
return { start: fmt(hoursAgo), end: fmt(now) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReplaySetupPanel({ map }: ReplaySetupPanelProps) {
|
||||||
|
const { start, end } = getDefaultTimeRange()
|
||||||
|
const [startTime, setStartTime] = useState(start)
|
||||||
|
const [endTime, setEndTime] = useState(end)
|
||||||
|
|
||||||
|
const connectionState = useReplayStore((s) => s.connectionState)
|
||||||
|
const querying = useReplayStore((s) => s.querying)
|
||||||
|
const queryCompleted = useReplayStore((s) => s.queryCompleted)
|
||||||
|
const receivedChunks = useReplayStore((s) => s.receivedChunks)
|
||||||
|
const totalChunks = useReplayStore((s) => s.totalChunks)
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/signal-batch/ws-tracks`
|
||||||
|
try {
|
||||||
|
await replayWebSocket.connect(wsUrl)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Replay] Connection failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
if (!map) return
|
||||||
|
const bounds = getViewportBounds(map)
|
||||||
|
const zoom = map.getZoom()
|
||||||
|
const startISO = startTime.replace('T', ' ') + ':00'
|
||||||
|
const endISO = endTime.replace('T', ' ') + ':00'
|
||||||
|
replayWebSocket.executeQuery(startISO, endISO, bounds, zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
replayWebSocket.cancelQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConnected = connectionState === 'connected'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 연결 상태 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
isConnected ? 'bg-green-500' : connectionState === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-muted">
|
||||||
|
{connectionState === 'connected' ? '연결됨' :
|
||||||
|
connectionState === 'connecting' ? '연결 중...' :
|
||||||
|
connectionState === 'error' ? '연결 실패' : '미연결'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isConnected && (
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={connectionState === 'connecting'}
|
||||||
|
className="rounded-md bg-primary px-2 py-1 text-xs text-white hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
연결
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시간 범위 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">시작</label>
|
||||||
|
<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 focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted">종료</label>
|
||||||
|
<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 focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 쿼리 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={querying ? handleCancel : handleQuery}
|
||||||
|
disabled={!isConnected}
|
||||||
|
className={`w-full rounded-md px-3 py-1.5 text-xs font-medium transition ${
|
||||||
|
querying
|
||||||
|
? 'bg-red-500 text-white hover:bg-red-600'
|
||||||
|
: 'bg-primary text-white hover:bg-primary/90 disabled:opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{querying ? '취소' : '현재 뷰포트 리플레이'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 진행 상태 */}
|
||||||
|
{(querying || queryCompleted) && (
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{querying && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-border">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: totalChunks > 0 ? `${(receivedChunks / totalChunks) * 100}%` : '50%',
|
||||||
|
animation: totalChunks === 0 ? 'pulse 1.5s ease-in-out infinite' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>{receivedChunks} 청크</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{queryCompleted && !querying && (
|
||||||
|
<span className="text-green-600">{receivedChunks} 청크 수신 완료</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { useMergedTrackStore } from '../stores/mergedTrackStore'
|
||||||
|
import type { InterpolatedPosition } from '../../vessel-map'
|
||||||
|
|
||||||
|
export const PLAYBACK_SPEEDS = [1, 10, 50, 100, 500, 1000] as const
|
||||||
|
|
||||||
|
interface AnimationState {
|
||||||
|
isPlaying: boolean
|
||||||
|
currentTime: number
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
playbackSpeed: number
|
||||||
|
loop: boolean
|
||||||
|
animationFrameId: number | null
|
||||||
|
lastFrameTime: number
|
||||||
|
|
||||||
|
/** 커서 기반 위치 캐시 (O(1) per frame) */
|
||||||
|
positionCursors: Map<string, number>
|
||||||
|
|
||||||
|
play: () => void
|
||||||
|
pause: () => void
|
||||||
|
stop: () => void
|
||||||
|
setPlaybackSpeed: (speed: number) => void
|
||||||
|
setCurrentTime: (time: number) => void
|
||||||
|
setProgressByRatio: (ratio: number) => void
|
||||||
|
toggleLoop: () => void
|
||||||
|
initTimeRange: () => void
|
||||||
|
getCurrentVesselPositions: () => InterpolatedPosition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAnimationStore = create<AnimationState>((set, get) => ({
|
||||||
|
isPlaying: false,
|
||||||
|
currentTime: 0,
|
||||||
|
startTime: 0,
|
||||||
|
endTime: 0,
|
||||||
|
playbackSpeed: 500,
|
||||||
|
loop: true,
|
||||||
|
animationFrameId: null,
|
||||||
|
lastFrameTime: 0,
|
||||||
|
positionCursors: new Map(),
|
||||||
|
|
||||||
|
play: () => {
|
||||||
|
const state = get()
|
||||||
|
if (state.isPlaying) return
|
||||||
|
|
||||||
|
const paths = useMergedTrackStore.getState().getAllMergedPaths()
|
||||||
|
if (paths.length === 0) return
|
||||||
|
|
||||||
|
// 시간 범위 초기화
|
||||||
|
if (state.startTime === 0 || state.endTime === 0) {
|
||||||
|
get().initTimeRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startTime, endTime, currentTime } = get()
|
||||||
|
if (currentTime >= endTime) {
|
||||||
|
set({ currentTime: startTime })
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
isPlaying: true,
|
||||||
|
lastFrameTime: performance.now(),
|
||||||
|
positionCursors: new Map(),
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
set({ positionCursors: new Map() })
|
||||||
|
} 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,
|
||||||
|
positionCursors: new Map(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }),
|
||||||
|
|
||||||
|
setCurrentTime: (time) => set({ currentTime: time, positionCursors: new Map() }),
|
||||||
|
|
||||||
|
setProgressByRatio: (ratio) => {
|
||||||
|
const { startTime, endTime } = get()
|
||||||
|
const time = startTime + (endTime - startTime) * Math.max(0, Math.min(1, ratio))
|
||||||
|
set({ currentTime: time, positionCursors: new Map() })
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleLoop: () => set((s) => ({ loop: !s.loop })),
|
||||||
|
|
||||||
|
initTimeRange: () => {
|
||||||
|
const paths = useMergedTrackStore.getState().getAllMergedPaths()
|
||||||
|
let min = Infinity, max = -Infinity
|
||||||
|
for (const p of paths) {
|
||||||
|
if (p.timestampsMs.length === 0) continue
|
||||||
|
if (p.timestampsMs[0] < min) min = p.timestampsMs[0]
|
||||||
|
if (p.timestampsMs[p.timestampsMs.length - 1] > max) max = p.timestampsMs[p.timestampsMs.length - 1]
|
||||||
|
}
|
||||||
|
if (min < max) {
|
||||||
|
set({ startTime: min, endTime: max, currentTime: min })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentVesselPositions: () => {
|
||||||
|
const { currentTime, positionCursors } = get()
|
||||||
|
const mergedStore = useMergedTrackStore.getState()
|
||||||
|
const positions: InterpolatedPosition[] = []
|
||||||
|
|
||||||
|
for (const [vesselId] of mergedStore.vesselChunks) {
|
||||||
|
const path = mergedStore.getMergedPath(vesselId)
|
||||||
|
if (!path || path.timestampsMs.length === 0) continue
|
||||||
|
|
||||||
|
const ts = path.timestampsMs
|
||||||
|
if (currentTime < ts[0] || currentTime > ts[ts.length - 1]) continue
|
||||||
|
|
||||||
|
// 커서 기반 선형 전진 (O(1) per frame)
|
||||||
|
let cursor = positionCursors.get(vesselId)
|
||||||
|
if (cursor === undefined || cursor >= ts.length || (cursor > 0 && ts[cursor - 1] > currentTime)) {
|
||||||
|
// fallback: 이진 탐색
|
||||||
|
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(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 = path.geometry[idx1][0]
|
||||||
|
lat = path.geometry[idx1][1]
|
||||||
|
heading = 0
|
||||||
|
speed = path.speeds[idx1] || 0
|
||||||
|
} else {
|
||||||
|
const ratio = (currentTime - ts[idx1]) / (ts[idx2] - ts[idx1])
|
||||||
|
const p1 = path.geometry[idx1]
|
||||||
|
const p2 = path.geometry[idx2]
|
||||||
|
lon = p1[0] + (p2[0] - p1[0]) * ratio
|
||||||
|
lat = p1[1] + (p2[1] - p1[1]) * ratio
|
||||||
|
speed = (path.speeds[idx1] || 0) + ((path.speeds[idx2] || 0) - (path.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
|
||||||
|
}
|
||||||
|
|
||||||
|
const vesselData = mergedStore.vesselChunks.get(vesselId)
|
||||||
|
positions.push({
|
||||||
|
vesselId,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
heading,
|
||||||
|
speed,
|
||||||
|
shipName: vesselData?.shipName || '',
|
||||||
|
shipKindCode: vesselData?.shipKindCode || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions
|
||||||
|
},
|
||||||
|
}))
|
||||||
7
frontend/src/features/viewport-replay/index.ts
Normal file
7
frontend/src/features/viewport-replay/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export { replayWebSocket } from './services/replayWebSocket'
|
||||||
|
export { useMergedTrackStore } from './stores/mergedTrackStore'
|
||||||
|
export { useReplayStore } from './stores/replayStore'
|
||||||
|
export { useAnimationStore, PLAYBACK_SPEEDS } from './hooks/useReplayAnimation'
|
||||||
|
export { useReplayLayer } from './components/ReplayLayer'
|
||||||
|
export { default as ReplaySetupPanel } from './components/ReplaySetupPanel'
|
||||||
|
export { default as ReplayControlPanel } from './components/ReplayControlPanel'
|
||||||
@ -0,0 +1,253 @@
|
|||||||
|
import { Client, type IMessage } from '@stomp/stompjs'
|
||||||
|
import { useMergedTrackStore } from '../stores/mergedTrackStore'
|
||||||
|
import { useReplayStore } from '../stores/replayStore'
|
||||||
|
import type { ViewportBounds } from '../../vessel-map'
|
||||||
|
|
||||||
|
const CONNECTION_TIMEOUT = 10_000
|
||||||
|
const QUERY_TIMEOUT = 300_000
|
||||||
|
|
||||||
|
export interface TrackQueryRequest {
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
viewport?: {
|
||||||
|
minLon: number
|
||||||
|
maxLon: number
|
||||||
|
minLat: number
|
||||||
|
maxLat: number
|
||||||
|
}
|
||||||
|
chunkedMode: boolean
|
||||||
|
chunkSize: number
|
||||||
|
simplificationMode: string
|
||||||
|
zoomLevel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackChunkResponse {
|
||||||
|
queryId: string
|
||||||
|
chunkIndex: number
|
||||||
|
totalChunks?: number | null
|
||||||
|
tracks?: TrackChunkData[]
|
||||||
|
mergedTracks?: TrackChunkData[]
|
||||||
|
compactTracks?: TrackChunkData[]
|
||||||
|
isLastChunk?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackChunkData {
|
||||||
|
vesselId: string
|
||||||
|
shipName?: string
|
||||||
|
shipKindCode?: string
|
||||||
|
nationalCode?: string
|
||||||
|
geometry?: [number, number][]
|
||||||
|
timestamps?: (string | number)[]
|
||||||
|
speeds?: number[]
|
||||||
|
totalDistance?: number
|
||||||
|
maxSpeed?: number
|
||||||
|
avgSpeed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 타임스탬프를 ms 단위로 정규화 */
|
||||||
|
function parseTimestamp(ts: string | number): number {
|
||||||
|
if (typeof ts === 'number') {
|
||||||
|
return ts < 1e12 ? ts * 1000 : ts
|
||||||
|
}
|
||||||
|
if (/^\d{10,}$/.test(ts)) {
|
||||||
|
return parseInt(ts, 10) * 1000
|
||||||
|
}
|
||||||
|
if (ts.includes(' ') && !ts.includes('T')) {
|
||||||
|
const [datePart, timePart] = ts.split(' ')
|
||||||
|
return new Date(`${datePart}T${timePart}`).getTime()
|
||||||
|
}
|
||||||
|
const parsed = new Date(ts).getTime()
|
||||||
|
return isNaN(parsed) ? 0 : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STOMP WebSocket 리플레이 서비스
|
||||||
|
* 싱글턴 — connect/disconnect/executeQuery/cancel
|
||||||
|
*/
|
||||||
|
class ReplayWebSocketService {
|
||||||
|
private client: Client | null = null
|
||||||
|
private currentQueryId: string | null = null
|
||||||
|
private queryTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
/** WebSocket 연결 */
|
||||||
|
connect(wsUrl: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.client?.connected) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const replayStore = useReplayStore.getState()
|
||||||
|
replayStore.setConnectionState('connecting')
|
||||||
|
|
||||||
|
this.client = new Client({
|
||||||
|
brokerURL: wsUrl,
|
||||||
|
reconnectDelay: 0,
|
||||||
|
connectionTimeout: CONNECTION_TIMEOUT,
|
||||||
|
heartbeatIncoming: 10_000,
|
||||||
|
heartbeatOutgoing: 10_000,
|
||||||
|
|
||||||
|
onConnect: () => {
|
||||||
|
replayStore.setConnectionState('connected')
|
||||||
|
this.setupSubscriptions()
|
||||||
|
resolve()
|
||||||
|
},
|
||||||
|
|
||||||
|
onStompError: (frame) => {
|
||||||
|
console.error('[ReplayWS] STOMP error:', frame.headers.message)
|
||||||
|
replayStore.setConnectionState('error')
|
||||||
|
reject(new Error(frame.headers.message))
|
||||||
|
},
|
||||||
|
|
||||||
|
onWebSocketError: () => {
|
||||||
|
replayStore.setConnectionState('error')
|
||||||
|
reject(new Error('WebSocket connection failed'))
|
||||||
|
},
|
||||||
|
|
||||||
|
onDisconnect: () => {
|
||||||
|
replayStore.setConnectionState('disconnected')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.client.activate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebSocket 연결 해제 */
|
||||||
|
disconnect(): void {
|
||||||
|
this.clearQueryTimeout()
|
||||||
|
if (this.client) {
|
||||||
|
this.client.deactivate()
|
||||||
|
this.client = null
|
||||||
|
}
|
||||||
|
useReplayStore.getState().setConnectionState('disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 항적 쿼리 실행 */
|
||||||
|
executeQuery(
|
||||||
|
startTime: string,
|
||||||
|
endTime: string,
|
||||||
|
viewport: ViewportBounds,
|
||||||
|
zoomLevel: number,
|
||||||
|
): void {
|
||||||
|
if (!this.client?.connected) {
|
||||||
|
console.error('[ReplayWS] Not connected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이전 쿼리 정리
|
||||||
|
if (this.currentQueryId) {
|
||||||
|
this.cancelQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
useMergedTrackStore.getState().clear()
|
||||||
|
useReplayStore.getState().startQuery()
|
||||||
|
|
||||||
|
const request: TrackQueryRequest = {
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
viewport: {
|
||||||
|
minLon: viewport.west,
|
||||||
|
maxLon: viewport.east,
|
||||||
|
minLat: viewport.south,
|
||||||
|
maxLat: viewport.north,
|
||||||
|
},
|
||||||
|
chunkedMode: true,
|
||||||
|
chunkSize: 20_000,
|
||||||
|
simplificationMode: 'AUTO',
|
||||||
|
zoomLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.publish({
|
||||||
|
destination: '/app/tracks/query',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
})
|
||||||
|
|
||||||
|
this.queryTimeoutId = setTimeout(() => {
|
||||||
|
console.warn('[ReplayWS] Query timeout')
|
||||||
|
useReplayStore.getState().completeQuery()
|
||||||
|
}, QUERY_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 진행 중인 쿼리 취소 */
|
||||||
|
cancelQuery(): void {
|
||||||
|
if (this.currentQueryId && this.client?.connected) {
|
||||||
|
this.client.publish({
|
||||||
|
destination: `/app/tracks/cancel/${this.currentQueryId}`,
|
||||||
|
body: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.clearQueryTimeout()
|
||||||
|
this.currentQueryId = null
|
||||||
|
useReplayStore.getState().completeQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSubscriptions(): void {
|
||||||
|
if (!this.client) return
|
||||||
|
|
||||||
|
this.client.subscribe('/user/queue/tracks/chunk', (msg: IMessage) => {
|
||||||
|
this.handleChunkMessage(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.client.subscribe('/user/queue/tracks/status', (msg: IMessage) => {
|
||||||
|
this.handleStatusMessage(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.client.subscribe('/user/queue/tracks/response', (msg: IMessage) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(msg.body)
|
||||||
|
if (data.queryId) {
|
||||||
|
this.currentQueryId = data.queryId
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleChunkMessage(msg: IMessage): void {
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(msg.body) as TrackChunkResponse
|
||||||
|
const tracks = chunk.tracks || chunk.mergedTracks || chunk.compactTracks || []
|
||||||
|
if (tracks.length === 0) return
|
||||||
|
|
||||||
|
// 타임스탬프 정규화
|
||||||
|
const normalizedTracks = tracks.map((t) => ({
|
||||||
|
...t,
|
||||||
|
timestampsMs: (t.timestamps || []).map(parseTimestamp),
|
||||||
|
}))
|
||||||
|
|
||||||
|
useMergedTrackStore.getState().addChunk(normalizedTracks)
|
||||||
|
|
||||||
|
const replayStore = useReplayStore.getState()
|
||||||
|
replayStore.updateProgress(
|
||||||
|
replayStore.receivedChunks + 1,
|
||||||
|
chunk.totalChunks ?? replayStore.totalChunks,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (chunk.isLastChunk) {
|
||||||
|
this.clearQueryTimeout()
|
||||||
|
replayStore.completeQuery()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ReplayWS] Chunk parse error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleStatusMessage(msg: IMessage): void {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(msg.body)
|
||||||
|
if (data.status === 'COMPLETED' || data.status === 'ERROR') {
|
||||||
|
this.clearQueryTimeout()
|
||||||
|
useReplayStore.getState().completeQuery()
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearQueryTimeout(): void {
|
||||||
|
if (this.queryTimeoutId) {
|
||||||
|
clearTimeout(this.queryTimeoutId)
|
||||||
|
this.queryTimeoutId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const replayWebSocket = new ReplayWebSocketService()
|
||||||
127
frontend/src/features/viewport-replay/stores/mergedTrackStore.ts
Normal file
127
frontend/src/features/viewport-replay/stores/mergedTrackStore.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { TrackSegment } from '../../vessel-map'
|
||||||
|
|
||||||
|
interface NormalizedChunkTrack {
|
||||||
|
vesselId: string
|
||||||
|
shipName?: string
|
||||||
|
shipKindCode?: string
|
||||||
|
nationalCode?: string
|
||||||
|
geometry?: [number, number][]
|
||||||
|
timestampsMs: number[]
|
||||||
|
speeds?: number[]
|
||||||
|
totalDistance?: number
|
||||||
|
maxSpeed?: number
|
||||||
|
avgSpeed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VesselChunkData {
|
||||||
|
shipName: string
|
||||||
|
shipKindCode: string
|
||||||
|
nationalCode: string
|
||||||
|
chunks: NormalizedChunkTrack[]
|
||||||
|
cachedPath: TrackSegment | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MergedTrackState {
|
||||||
|
/** MMSI → 청크 데이터 */
|
||||||
|
vesselChunks: Map<string, VesselChunkData>
|
||||||
|
|
||||||
|
/** 청크 추가 (WS 메시지 수신 시) */
|
||||||
|
addChunk: (tracks: NormalizedChunkTrack[]) => void
|
||||||
|
|
||||||
|
/** 특정 선박의 병합된 경로 (캐시) */
|
||||||
|
getMergedPath: (vesselId: string) => TrackSegment | null
|
||||||
|
|
||||||
|
/** 전체 선박의 병합된 경로 목록 */
|
||||||
|
getAllMergedPaths: () => TrackSegment[]
|
||||||
|
|
||||||
|
/** 전체 초기화 */
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 청크들을 시간순 단일 TrackSegment로 병합 */
|
||||||
|
function buildMergedPath(data: VesselChunkData): TrackSegment {
|
||||||
|
const allPoints: { lon: number; lat: number; ts: number; speed: number }[] = []
|
||||||
|
|
||||||
|
for (const chunk of data.chunks) {
|
||||||
|
const geom = chunk.geometry || []
|
||||||
|
const ts = chunk.timestampsMs
|
||||||
|
const speeds = chunk.speeds || []
|
||||||
|
|
||||||
|
for (let i = 0; i < geom.length; i++) {
|
||||||
|
allPoints.push({
|
||||||
|
lon: geom[i][0],
|
||||||
|
lat: geom[i][1],
|
||||||
|
ts: ts[i] || 0,
|
||||||
|
speed: speeds[i] || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시간순 정렬
|
||||||
|
allPoints.sort((a, b) => a.ts - b.ts)
|
||||||
|
|
||||||
|
return {
|
||||||
|
vesselId: data.chunks[0]?.vesselId || '',
|
||||||
|
shipName: data.shipName,
|
||||||
|
shipKindCode: data.shipKindCode,
|
||||||
|
nationalCode: data.nationalCode,
|
||||||
|
geometry: allPoints.map((p) => [p.lon, p.lat] as [number, number]),
|
||||||
|
timestampsMs: allPoints.map((p) => p.ts),
|
||||||
|
speeds: allPoints.map((p) => p.speed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMergedTrackStore = create<MergedTrackState>((set, get) => ({
|
||||||
|
vesselChunks: new Map(),
|
||||||
|
|
||||||
|
addChunk: (tracks) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.vesselChunks)
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
const existing = next.get(track.vesselId)
|
||||||
|
if (existing) {
|
||||||
|
existing.chunks.push(track)
|
||||||
|
existing.cachedPath = null // 캐시 무효화
|
||||||
|
} else {
|
||||||
|
next.set(track.vesselId, {
|
||||||
|
shipName: track.shipName || '',
|
||||||
|
shipKindCode: track.shipKindCode || '000027',
|
||||||
|
nationalCode: track.nationalCode || '',
|
||||||
|
chunks: [track],
|
||||||
|
cachedPath: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vesselChunks: next }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getMergedPath: (vesselId) => {
|
||||||
|
const data = get().vesselChunks.get(vesselId)
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
if (!data.cachedPath) {
|
||||||
|
data.cachedPath = buildMergedPath(data)
|
||||||
|
}
|
||||||
|
return data.cachedPath
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllMergedPaths: () => {
|
||||||
|
const { vesselChunks } = get()
|
||||||
|
const getMergedPath = get().getMergedPath
|
||||||
|
const paths: TrackSegment[] = []
|
||||||
|
|
||||||
|
for (const vesselId of vesselChunks.keys()) {
|
||||||
|
const path = getMergedPath(vesselId)
|
||||||
|
if (path && path.geometry.length >= 2) {
|
||||||
|
paths.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => set({ vesselChunks: new Map() }),
|
||||||
|
}))
|
||||||
74
frontend/src/features/viewport-replay/stores/replayStore.ts
Normal file
74
frontend/src/features/viewport-replay/stores/replayStore.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
|
interface ReplayState {
|
||||||
|
/** WebSocket 연결 상태 */
|
||||||
|
connectionState: ConnectionState
|
||||||
|
|
||||||
|
/** 쿼리 진행 중 여부 */
|
||||||
|
querying: boolean
|
||||||
|
|
||||||
|
/** 쿼리 완료 여부 */
|
||||||
|
queryCompleted: boolean
|
||||||
|
|
||||||
|
/** 수신 청크 수 */
|
||||||
|
receivedChunks: number
|
||||||
|
|
||||||
|
/** 예상 전체 청크 수 */
|
||||||
|
totalChunks: number
|
||||||
|
|
||||||
|
/** 연결 상태 업데이트 */
|
||||||
|
setConnectionState: (state: ConnectionState) => void
|
||||||
|
|
||||||
|
/** 쿼리 시작 */
|
||||||
|
startQuery: () => void
|
||||||
|
|
||||||
|
/** 쿼리 진행 업데이트 */
|
||||||
|
updateProgress: (received: number, total: number) => void
|
||||||
|
|
||||||
|
/** 쿼리 완료 */
|
||||||
|
completeQuery: () => void
|
||||||
|
|
||||||
|
/** 전체 초기화 */
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReplayStore = create<ReplayState>((set) => ({
|
||||||
|
connectionState: 'disconnected',
|
||||||
|
querying: false,
|
||||||
|
queryCompleted: false,
|
||||||
|
receivedChunks: 0,
|
||||||
|
totalChunks: 0,
|
||||||
|
|
||||||
|
setConnectionState: (connectionState) => set({ connectionState }),
|
||||||
|
|
||||||
|
startQuery: () =>
|
||||||
|
set({
|
||||||
|
querying: true,
|
||||||
|
queryCompleted: false,
|
||||||
|
receivedChunks: 0,
|
||||||
|
totalChunks: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateProgress: (received, total) =>
|
||||||
|
set({
|
||||||
|
receivedChunks: received,
|
||||||
|
totalChunks: total,
|
||||||
|
}),
|
||||||
|
|
||||||
|
completeQuery: () =>
|
||||||
|
set({
|
||||||
|
querying: false,
|
||||||
|
queryCompleted: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
connectionState: 'disconnected',
|
||||||
|
querying: false,
|
||||||
|
queryCompleted: false,
|
||||||
|
receivedChunks: 0,
|
||||||
|
totalChunks: 0,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@ -125,14 +125,11 @@ const en = {
|
|||||||
'explorer.title': 'API Explorer',
|
'explorer.title': 'API Explorer',
|
||||||
'explorer.apiType': 'API Type',
|
'explorer.apiType': 'API Type',
|
||||||
'explorer.recentPositions': 'Recent Positions',
|
'explorer.recentPositions': 'Recent Positions',
|
||||||
'explorer.haeguTracks': 'Area Tracks',
|
|
||||||
'explorer.vesselTracks': 'Vessel Tracks',
|
'explorer.vesselTracks': 'Vessel Tracks',
|
||||||
|
'explorer.viewportReplay': 'Viewport Replay',
|
||||||
'explorer.parameters': 'Parameters',
|
'explorer.parameters': 'Parameters',
|
||||||
'explorer.positionsDesc': 'Fetches vessels with position updates within 10 minutes.',
|
'explorer.positionsDesc': 'Fetches vessels with position updates within 10 minutes.',
|
||||||
'explorer.haeguDesc': 'Fetches vessel tracks within a specific area as GeoJSON.',
|
|
||||||
'explorer.vesselDesc': 'Fetches tracks for specific vessels by MMSI list.',
|
'explorer.vesselDesc': 'Fetches tracks for specific vessels by MMSI list.',
|
||||||
'explorer.comingSoon': 'Detailed API Demo (Coming Soon)',
|
|
||||||
'explorer.comingSoonDesc': 'Request/Response panels, track layers, replay',
|
|
||||||
|
|
||||||
// Abnormal Tracks
|
// Abnormal Tracks
|
||||||
'abnormal.title': 'Abnormal Tracks',
|
'abnormal.title': 'Abnormal Tracks',
|
||||||
|
|||||||
@ -125,14 +125,11 @@ const ko = {
|
|||||||
'explorer.title': 'API 탐색기',
|
'explorer.title': 'API 탐색기',
|
||||||
'explorer.apiType': 'API 유형',
|
'explorer.apiType': 'API 유형',
|
||||||
'explorer.recentPositions': '최근 위치',
|
'explorer.recentPositions': '최근 위치',
|
||||||
'explorer.haeguTracks': '해구별 항적',
|
|
||||||
'explorer.vesselTracks': '선박별 항적',
|
'explorer.vesselTracks': '선박별 항적',
|
||||||
|
'explorer.viewportReplay': '뷰포트 리플레이',
|
||||||
'explorer.parameters': '파라미터',
|
'explorer.parameters': '파라미터',
|
||||||
'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.',
|
'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.',
|
||||||
'explorer.haeguDesc': '특정 해구 내 선박 항적을 GeoJSON 형태로 조회합니다.',
|
|
||||||
'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.',
|
'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.',
|
||||||
'explorer.comingSoon': '상세 API 시연 (향후 구현)',
|
|
||||||
'explorer.comingSoonDesc': 'Request/Response 패널, 항적 레이어, 리플레이',
|
|
||||||
|
|
||||||
// Abnormal Tracks
|
// Abnormal Tracks
|
||||||
'abnormal.title': '비정상 항적',
|
'abnormal.title': '비정상 항적',
|
||||||
|
|||||||
@ -1,30 +1,90 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import { useI18n } from '../hooks/useI18n.ts'
|
import { useI18n } from '../hooks/useI18n.ts'
|
||||||
import MapContainer from '../components/map/MapContainer.tsx'
|
import MapContainer from '../components/map/MapContainer.tsx'
|
||||||
import Sidebar from '../components/layout/Sidebar.tsx'
|
import Sidebar from '../components/layout/Sidebar.tsx'
|
||||||
import type maplibregl from 'maplibre-gl'
|
import type maplibregl from 'maplibre-gl'
|
||||||
|
import type { Layer } from '@deck.gl/core'
|
||||||
|
import {
|
||||||
|
useRecentPositions,
|
||||||
|
useRecentPositionsLayer,
|
||||||
|
usePositionStore,
|
||||||
|
VesselPopup,
|
||||||
|
PositionFilterPanel,
|
||||||
|
} from '../features/recent-positions'
|
||||||
|
import {
|
||||||
|
useVesselTracksLayer,
|
||||||
|
TrackQueryPanel,
|
||||||
|
TrackInfoPanel,
|
||||||
|
} from '../features/vessel-tracks'
|
||||||
|
import {
|
||||||
|
useReplayLayer,
|
||||||
|
ReplaySetupPanel,
|
||||||
|
ReplayControlPanel,
|
||||||
|
} from '../features/viewport-replay'
|
||||||
|
|
||||||
type ApiMode = 'haegu' | 'vessel' | 'positions'
|
type ApiMode = 'positions' | 'vessel' | 'replay'
|
||||||
|
|
||||||
export default function ApiExplorer() {
|
export default function ApiExplorer() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [mode, setMode] = useState<ApiMode>('positions')
|
const [mode, setMode] = useState<ApiMode>('positions')
|
||||||
const [, setMap] = useState<maplibregl.Map | null>(null)
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
|
const [zoom, setZoom] = useState(7)
|
||||||
|
const [trackMmsi, setTrackMmsi] = useState<string | undefined>()
|
||||||
|
const [replayLayers, setReplayLayers] = useState<Layer[]>([])
|
||||||
|
|
||||||
|
const selectVessel = usePositionStore((s) => s.selectVessel)
|
||||||
|
|
||||||
|
// 최근 위치 30초 폴링 (positions 모드일 때만)
|
||||||
|
useRecentPositions(10, mode === 'positions')
|
||||||
|
|
||||||
|
// Deck.gl 레이어 — positions
|
||||||
|
const positionLayers = useRecentPositionsLayer({
|
||||||
|
zoom,
|
||||||
|
onVesselClick: (mmsi) => selectVessel(mmsi),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deck.gl 레이어 — vessel tracks
|
||||||
|
const trackLayers = useVesselTracksLayer({ zoom })
|
||||||
|
|
||||||
|
// Deck.gl 레이어 — replay (콜백으로 업데이트)
|
||||||
|
const handleReplayLayersUpdate = useCallback((layers: Layer[]) => {
|
||||||
|
setReplayLayers(layers)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useReplayLayer({
|
||||||
|
zoom,
|
||||||
|
onLayersUpdate: handleReplayLayersUpdate,
|
||||||
|
})
|
||||||
|
|
||||||
const handleMapReady = useCallback((m: maplibregl.Map) => {
|
const handleMapReady = useCallback((m: maplibregl.Map) => {
|
||||||
setMap(m)
|
mapRef.current = m
|
||||||
|
setZoom(m.getZoom())
|
||||||
|
m.on('zoom', () => setZoom(m.getZoom()))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 최근위치 → 항적조회 전환
|
||||||
|
const handleTrackQuery = useCallback((mmsi: string) => {
|
||||||
|
setTrackMmsi(mmsi)
|
||||||
|
setMode('vessel')
|
||||||
|
selectVessel(null)
|
||||||
|
}, [selectVessel])
|
||||||
|
|
||||||
|
// 모드별 Deck.gl 레이어
|
||||||
|
const deckLayers: Layer[] =
|
||||||
|
mode === 'positions' ? positionLayers :
|
||||||
|
mode === 'vessel' ? trackLayers :
|
||||||
|
mode === 'replay' ? replayLayers :
|
||||||
|
[]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<Sidebar width={320}>
|
<Sidebar width={320}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 제목 */}
|
|
||||||
<h2 className="text-lg font-bold">{t('explorer.title')}</h2>
|
<h2 className="text-lg font-bold">{t('explorer.title')}</h2>
|
||||||
|
|
||||||
{/* API 유형 선택 */}
|
{/* 모드 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-muted">
|
<label className="mb-1 block text-xs font-medium text-muted">
|
||||||
{t('explorer.apiType')}
|
{t('explorer.apiType')}
|
||||||
@ -32,8 +92,8 @@ export default function ApiExplorer() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{([
|
{([
|
||||||
{ value: 'positions' as const, label: t('explorer.recentPositions') },
|
{ value: 'positions' as const, label: t('explorer.recentPositions') },
|
||||||
{ value: 'haegu' as const, label: t('explorer.haeguTracks') },
|
|
||||||
{ value: 'vessel' as const, label: t('explorer.vesselTracks') },
|
{ value: 'vessel' as const, label: t('explorer.vesselTracks') },
|
||||||
|
{ value: 'replay' as const, label: '뷰포트 리플레이' },
|
||||||
] as const).map(opt => (
|
] as const).map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@ -50,55 +110,46 @@ export default function ApiExplorer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 파라미터 영역 */}
|
{/* 모드별 사이드바 */}
|
||||||
<div className="rounded-lg border border-border p-3">
|
{mode === 'positions' && (
|
||||||
<div className="mb-2 text-xs font-medium text-muted">
|
<div className="rounded-lg border border-border p-3">
|
||||||
{t('explorer.parameters')}
|
<div className="mb-2 text-xs font-medium text-muted">선종 필터</div>
|
||||||
|
<PositionFilterPanel />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === 'positions' && (
|
{mode === 'vessel' && (
|
||||||
<div className="space-y-2 text-sm text-muted">
|
<div className="rounded-lg border border-border p-3">
|
||||||
<p>GET /api/v1/vessels/recent-positions</p>
|
<div className="mb-2 text-xs font-medium text-muted">항적 조회</div>
|
||||||
<p className="text-xs">{t('explorer.positionsDesc')}</p>
|
<TrackQueryPanel initialMmsi={trackMmsi} />
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === 'haegu' && (
|
|
||||||
<div className="space-y-2 text-sm text-muted">
|
|
||||||
<p>GET /api/v2/tracks/haegu/:no</p>
|
|
||||||
<p className="text-xs">{t('explorer.haeguDesc')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === 'vessel' && (
|
|
||||||
<div className="space-y-2 text-sm text-muted">
|
|
||||||
<p>POST /api/v2/tracks/vessels</p>
|
|
||||||
<p className="text-xs">{t('explorer.vesselDesc')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 향후 구현 예정 안내 */}
|
|
||||||
<div className="rounded-lg border border-dashed border-border p-3 text-center">
|
|
||||||
<div className="text-xs text-muted">{t('explorer.comingSoon')}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted opacity-60">
|
|
||||||
{t('explorer.comingSoonDesc')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{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} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map area */}
|
{/* Map area */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<MapContainer onMapReady={handleMapReady} />
|
<MapContainer onMapReady={handleMapReady} deckLayers={deckLayers} />
|
||||||
|
|
||||||
{/* 모드 표시 오버레이 */}
|
{/* 모드 표시 */}
|
||||||
<div className="absolute left-3 top-3 rounded-md bg-surface/90 px-3 py-1.5 text-xs font-medium shadow-sm backdrop-blur">
|
<div className="absolute left-3 top-3 rounded-md bg-surface/90 px-3 py-1.5 text-xs font-medium shadow-sm backdrop-blur">
|
||||||
{mode === 'positions' && t('explorer.recentPositions')}
|
{mode === 'positions' && t('explorer.recentPositions')}
|
||||||
{mode === 'haegu' && t('explorer.haeguTracks')}
|
|
||||||
{mode === 'vessel' && t('explorer.vesselTracks')}
|
{mode === 'vessel' && t('explorer.vesselTracks')}
|
||||||
|
{mode === 'replay' && '뷰포트 리플레이'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 모드별 오버레이 */}
|
||||||
|
{mode === 'positions' && <VesselPopup onTrackQuery={handleTrackQuery} />}
|
||||||
|
{mode === 'vessel' && <TrackInfoPanel />}
|
||||||
|
{mode === 'replay' && <ReplayControlPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user