signal-batch/frontend/src/pages/ApiExplorer.tsx
htlee f41463d0f2 feat: 항적/리플레이 선종 아이콘 표시 수정 + Raw Data 패널 추가
- 속도 0일 때 이동 아이콘으로 표시하여 선종별 색상/형태 구분
- ApiExplorer 좌측 패널에 Swagger 스타일 JSON 응답 데이터 표시
- 3모드(최근위치/항적/리플레이) 각각 선택 데이터 실시간 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:59:33 +09:00

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