- 속도 0일 때 이동 아이콘으로 표시하여 선종별 색상/형태 구분 - ApiExplorer 좌측 패널에 Swagger 스타일 JSON 응답 데이터 표시 - 3모드(최근위치/항적/리플레이) 각각 선택 데이터 실시간 표시 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
251 lines
8.3 KiB
TypeScript
251 lines
8.3 KiB
TypeScript
import { useState, useCallback, useRef, useMemo } from 'react'
|
|
import { useI18n } from '../hooks/useI18n.ts'
|
|
import MapContainer from '../components/map/MapContainer.tsx'
|
|
import Sidebar from '../components/layout/Sidebar.tsx'
|
|
import RawDataPanel from '../components/RawDataPanel.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 { useTrackStore } from '../features/vessel-tracks/stores/trackStore'
|
|
import {
|
|
useReplayLayer,
|
|
useReplayStore,
|
|
useMergedTrackStore,
|
|
ReplaySetupPanel,
|
|
ReplayControlPanel,
|
|
} from '../features/viewport-replay'
|
|
|
|
type ApiMode = 'positions' | 'vessel' | 'replay'
|
|
|
|
/** 최근위치 — 선택된 선박의 원본 데이터 */
|
|
function usePositionRawData() {
|
|
const selectedMmsi = usePositionStore((s) => s.selectedMmsi)
|
|
const positions = usePositionStore((s) => s.positions)
|
|
|
|
return useMemo(() => {
|
|
if (!selectedMmsi) return null
|
|
const vessel = positions.get(selectedMmsi)
|
|
return vessel ?? null
|
|
}, [selectedMmsi, positions])
|
|
}
|
|
|
|
/** 항적 — 조회 결과 요약 데이터 */
|
|
function useTrackRawData() {
|
|
const tracks = useTrackStore((s) => s.tracks)
|
|
|
|
return useMemo(() => {
|
|
if (tracks.length === 0) return null
|
|
return tracks.map((t) => ({
|
|
vesselId: t.vesselId,
|
|
shipName: t.shipName,
|
|
shipKindCode: t.shipKindCode,
|
|
nationalCode: t.nationalCode,
|
|
pointCount: t.geometry.length,
|
|
totalDistance: t.totalDistance,
|
|
avgSpeed: t.avgSpeed,
|
|
maxSpeed: t.maxSpeed,
|
|
timeRange: t.timestampsMs.length >= 2
|
|
? [new Date(t.timestampsMs[0]).toISOString(), new Date(t.timestampsMs[t.timestampsMs.length - 1]).toISOString()]
|
|
: null,
|
|
}))
|
|
}, [tracks])
|
|
}
|
|
|
|
/** 리플레이 — 청크 병합 결과 데이터 */
|
|
function useReplayRawData() {
|
|
const vesselChunks = useMergedTrackStore((s) => s.vesselChunks)
|
|
const queryCompleted = useReplayStore((s) => s.queryCompleted)
|
|
const receivedChunks = useReplayStore((s) => s.receivedChunks)
|
|
|
|
return useMemo(() => {
|
|
if (vesselChunks.size === 0) return null
|
|
const vessels: Record<string, unknown>[] = []
|
|
for (const [mmsi, data] of vesselChunks) {
|
|
vessels.push({
|
|
vesselId: mmsi,
|
|
shipName: data.shipName,
|
|
shipKindCode: data.shipKindCode,
|
|
nationalCode: data.nationalCode,
|
|
chunkCount: data.chunks.length,
|
|
totalPoints: data.chunks.reduce((sum, c) => sum + (c.geometry?.length ?? 0), 0),
|
|
})
|
|
}
|
|
return {
|
|
queryCompleted,
|
|
receivedChunks,
|
|
vesselCount: vessels.length,
|
|
vessels,
|
|
}
|
|
}, [vesselChunks, queryCompleted, receivedChunks])
|
|
}
|
|
|
|
export default function ApiExplorer() {
|
|
const { t } = useI18n()
|
|
const [mode, setMode] = useState<ApiMode>('positions')
|
|
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) => {
|
|
mapRef.current = m
|
|
setZoom(m.getZoom())
|
|
m.on('zoom', () => setZoom(m.getZoom()))
|
|
}, [])
|
|
|
|
// 최근위치 → 항적조회 전환
|
|
const handleTrackQuery = useCallback((mmsi: string) => {
|
|
setTrackMmsi(mmsi)
|
|
setMode('vessel')
|
|
selectVessel(null)
|
|
}, [selectVessel])
|
|
|
|
// Raw Data 훅
|
|
const positionData = usePositionRawData()
|
|
const trackData = useTrackRawData()
|
|
const replayData = useReplayRawData()
|
|
|
|
// 모드별 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={340}>
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-bold">{t('explorer.title')}</h2>
|
|
|
|
{/* 모드 선택 */}
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-muted">
|
|
{t('explorer.apiType')}
|
|
</label>
|
|
<div className="space-y-1">
|
|
{([
|
|
{ value: 'positions' as const, label: t('explorer.recentPositions') },
|
|
{ value: 'vessel' as const, label: t('explorer.vesselTracks') },
|
|
{ value: 'replay' as const, label: '뷰포트 리플레이' },
|
|
] as const).map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setMode(opt.value)}
|
|
className={`w-full rounded-md px-3 py-2 text-left text-sm transition ${
|
|
mode === opt.value
|
|
? 'bg-primary/10 font-medium text-primary'
|
|
: 'text-foreground hover:bg-surface-hover'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모드별 사이드바 */}
|
|
{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 === '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>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{/* Response Data — Swagger Response 스타일 */}
|
|
{mode === 'positions' && (
|
|
<RawDataPanel
|
|
title="선박 상세 (클릭)"
|
|
data={positionData}
|
|
emptyText="지도에서 선박을 클릭하세요"
|
|
/>
|
|
)}
|
|
{mode === 'vessel' && (
|
|
<RawDataPanel
|
|
title="항적 조회 결과"
|
|
data={trackData}
|
|
emptyText="MMSI를 입력하고 조회하세요"
|
|
/>
|
|
)}
|
|
{mode === 'replay' && (
|
|
<RawDataPanel
|
|
title="리플레이 데이터"
|
|
data={replayData}
|
|
emptyText="뷰포트 리플레이를 실행하세요"
|
|
/>
|
|
)}
|
|
</div>
|
|
</Sidebar>
|
|
</div>
|
|
|
|
{/* Map area */}
|
|
<div className="relative flex-1">
|
|
<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 === 'vessel' && t('explorer.vesselTracks')}
|
|
{mode === 'replay' && '뷰포트 리플레이'}
|
|
</div>
|
|
|
|
{/* 모드별 오버레이 */}
|
|
{mode === 'positions' && <VesselPopup onTrackQuery={handleTrackQuery} />}
|
|
{mode === 'vessel' && <TrackInfoPanel />}
|
|
{mode === 'replay' && <ReplayControlPanel />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|