feat: Ship-GIS 기능 이관 — 최근위치/선박항적/뷰포트 리플레이 #61

병합
htlee feature/dashboard-phase-1 에서 develop 로 1 commits 를 머지했습니다 2026-02-20 15:21:42 +09:00
37개의 변경된 파일3701개의 추가작업 그리고 62개의 파일을 삭제

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -10,11 +10,17 @@
"preview": "vite preview"
},
"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",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"recharts": "^2.15.3"
"recharts": "^2.15.3",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

파일 보기

@ -8,24 +8,37 @@ export interface HaeguBoundary {
center_lat: number
}
/** CompactVesselTrack.java 기준 */
export interface VesselTrackResult {
mmsi: string
vesselId: string
nationalCode: string
shipKindCode: string
shipKindCode?: string
shipName?: string
shipType?: string
geometry: number[][]
timestamps: number[]
timestamps: string[] // 백엔드가 문자열 배열로 전송
speeds: number[]
totalDistance?: number
avgSpeed?: number
maxSpeed?: number
pointCount?: number
}
/** RecentVesselPositionDto.java 기준 */
export interface RecentPosition {
mmsi: string
lat: number
imo?: number
lon: number
lat: number
sog: number
cog: number
lastUpdate: string
vesselName?: string
shipNm?: string
shipTy?: string
shipKindCode?: string
nationalCode?: string
lastUpdate: string
shipImagePath?: string | null
shipImageCount?: number | null
}
export const gisApi = {

파일 보기

@ -1,6 +1,8 @@
import { useRef, useEffect, useState } from 'react'
import maplibregl from 'maplibre-gl'
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 {
MAP_CENTER,
@ -13,12 +15,14 @@ import {
interface MapContainerProps {
onMapReady?: (map: maplibregl.Map) => void
deckLayers?: Layer[]
className?: string
}
export default function MapContainer({ onMapReady, className = '' }: MapContainerProps) {
export default function MapContainer({ onMapReady, deckLayers, className = '' }: MapContainerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const overlayRef = useRef<MapboxOverlay | null>(null)
const { theme } = useTheme()
const [ready, setReady] = useState(false)
@ -41,6 +45,13 @@ export default function MapContainer({ onMapReady, className = '' }: MapContaine
'bottom-right',
)
const overlay = new MapboxOverlay({
interleaved: false,
layers: [],
})
map.addControl(overlay)
overlayRef.current = overlay
map.on('load', () => {
mapRef.current = map
setReady(true)
@ -48,12 +59,19 @@ export default function MapContainer({ onMapReady, className = '' }: MapContaine
})
return () => {
overlayRef.current = null
mapRef.current = null
map.remove()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/* Deck.gl 레이어 업데이트 */
useEffect(() => {
if (!overlayRef.current || !ready) return
overlayRef.current.setProps({ layers: deckLayers || [] })
}, [deckLayers, ready])
/* 테마 변경 시 스타일 교체 */
useEffect(() => {
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"
>
&times;
</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 }
}

파일 보기

@ -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
},
}))

Binary file not shown.

After

Width:  |  Height:  |  크기: 10 KiB

파일 보기

@ -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',
}

파일 보기

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

파일 보기

@ -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'

파일 보기

@ -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],
},
})
}

파일 보기

@ -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],
},
})
}

파일 보기

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

파일 보기

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

파일 보기

@ -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',
}))
}

파일 보기

@ -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])
}

파일 보기

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

파일 보기

@ -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'

파일 보기

@ -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="정지"
>
&#9632;
</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="반복"
>
&#x21BB;
</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>
)
}

파일 보기

@ -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
},
}))

파일 보기

@ -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()

파일 보기

@ -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() }),
}))

파일 보기

@ -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.apiType': 'API Type',
'explorer.recentPositions': 'Recent Positions',
'explorer.haeguTracks': 'Area Tracks',
'explorer.vesselTracks': 'Vessel Tracks',
'explorer.viewportReplay': 'Viewport Replay',
'explorer.parameters': 'Parameters',
'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.comingSoon': 'Detailed API Demo (Coming Soon)',
'explorer.comingSoonDesc': 'Request/Response panels, track layers, replay',
// Abnormal Tracks
'abnormal.title': 'Abnormal Tracks',

파일 보기

@ -125,14 +125,11 @@ const ko = {
'explorer.title': 'API 탐색기',
'explorer.apiType': 'API 유형',
'explorer.recentPositions': '최근 위치',
'explorer.haeguTracks': '해구별 항적',
'explorer.vesselTracks': '선박별 항적',
'explorer.viewportReplay': '뷰포트 리플레이',
'explorer.parameters': '파라미터',
'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.',
'explorer.haeguDesc': '특정 해구 내 선박 항적을 GeoJSON 형태로 조회합니다.',
'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.',
'explorer.comingSoon': '상세 API 시연 (향후 구현)',
'explorer.comingSoonDesc': 'Request/Response 패널, 항적 레이어, 리플레이',
// Abnormal Tracks
'abnormal.title': '비정상 항적',

파일 보기

@ -1,30 +1,90 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef } from 'react'
import { useI18n } from '../hooks/useI18n.ts'
import MapContainer from '../components/map/MapContainer.tsx'
import Sidebar from '../components/layout/Sidebar.tsx'
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() {
const { t } = useI18n()
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) => {
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 (
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
{/* Sidebar */}
<div className="relative flex">
<Sidebar width={320}>
<div className="space-y-4">
{/* 제목 */}
<h2 className="text-lg font-bold">{t('explorer.title')}</h2>
{/* API 유형 선택 */}
{/* 모드 선택 */}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
{t('explorer.apiType')}
@ -32,8 +92,8 @@ export default function ApiExplorer() {
<div className="space-y-1">
{([
{ 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: 'replay' as const, label: '뷰포트 리플레이' },
] as const).map(opt => (
<button
key={opt.value}
@ -50,55 +110,46 @@ export default function ApiExplorer() {
</div>
</div>
{/* 파라미터 영역 */}
<div className="rounded-lg border border-border p-3">
<div className="mb-2 text-xs font-medium text-muted">
{t('explorer.parameters')}
{/* 모드별 사이드바 */}
{mode === 'positions' && (
<div className="rounded-lg border border-border p-3">
<div className="mb-2 text-xs font-medium text-muted"> </div>
<PositionFilterPanel />
</div>
)}
{mode === 'positions' && (
<div className="space-y-2 text-sm text-muted">
<p>GET /api/v1/vessels/recent-positions</p>
<p className="text-xs">{t('explorer.positionsDesc')}</p>
</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')}
{mode === 'vessel' && (
<div className="rounded-lg border border-border p-3">
<div className="mb-2 text-xs font-medium text-muted"> </div>
<TrackQueryPanel initialMmsi={trackMmsi} />
</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>
</Sidebar>
</div>
{/* Map area */}
<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">
{mode === 'positions' && t('explorer.recentPositions')}
{mode === 'haegu' && t('explorer.haeguTracks')}
{mode === 'vessel' && t('explorer.vesselTracks')}
{mode === 'replay' && '뷰포트 리플레이'}
</div>
{/* 모드별 오버레이 */}
{mode === 'positions' && <VesselPopup onTrackQuery={handleTrackQuery} />}
{mode === 'vessel' && <TrackInfoPanel />}
{mode === 'replay' && <ReplayControlPanel />}
</div>
</div>
)