diff --git a/frontend/src/components/RawDataPanel.tsx b/frontend/src/components/RawDataPanel.tsx
new file mode 100644
index 0000000..be0fd33
--- /dev/null
+++ b/frontend/src/components/RawDataPanel.tsx
@@ -0,0 +1,60 @@
+import { useState, useMemo } from 'react'
+
+interface RawDataPanelProps {
+ /** 패널 제목 */
+ title: string
+ /** 표시할 데이터 객체 (JSON.stringify 대상) */
+ data: unknown
+ /** 데이터 없을 때 안내 문구 */
+ emptyText?: string
+}
+
+/**
+ * Swagger Response처럼 실제 API 응답 데이터를 JSON으로 표시하는 패널
+ * 좌측 사이드바 하단에 남은 영역을 채움
+ */
+export default function RawDataPanel({ title, data, emptyText = '데이터 없음' }: RawDataPanelProps) {
+ const [collapsed, setCollapsed] = useState(false)
+
+ const jsonStr = useMemo(() => {
+ if (data == null) return null
+ try {
+ return JSON.stringify(data, null, 2)
+ } catch {
+ return String(data)
+ }
+ }, [data])
+
+ const hasData = jsonStr != null && jsonStr !== 'null' && jsonStr !== '{}' && jsonStr !== '[]'
+
+ return (
+
+ {/* 헤더 */}
+
+
+ {/* 본문 */}
+ {!collapsed && (
+
+ {hasData ? (
+
+ {jsonStr}
+
+ ) : (
+
{emptyText}
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/features/vessel-tracks/components/VesselTracksLayer.tsx b/frontend/src/features/vessel-tracks/components/VesselTracksLayer.tsx
index 542f690..617c201 100644
--- a/frontend/src/features/vessel-tracks/components/VesselTracksLayer.tsx
+++ b/frontend/src/features/vessel-tracks/components/VesselTracksLayer.tsx
@@ -34,17 +34,22 @@ export function useVesselTracksLayer({ zoom }: VesselTracksLayerProps): Layer[]
}))
// 2. 현재 위치 아이콘 레이어
+ // 항적 데이터의 speeds가 0 (백엔드 WKT→좌표 변환 시 속도 미포함)
+ // 항적이 있으면 운항 중이므로 이동 아이콘 표시
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,
- }))
+ const iconData: VesselIconData[] = positions.map((p) => {
+ const displaySog = p.speed > 0 ? p.speed : 2
+ return {
+ mmsi: p.vesselId,
+ position: [p.lon, p.lat],
+ angle: p.heading,
+ icon: getIconKey(p.shipKindCode, displaySog),
+ size: getIconSize(zoom, p.shipKindCode, displaySog),
+ shipNm: p.shipName,
+ shipKindCode: p.shipKindCode,
+ sog: p.speed,
+ }
+ })
return [
createTrackPathLayer({ id: 'vessel-track-path', data: pathData }),
diff --git a/frontend/src/features/viewport-replay/components/ReplayLayer.tsx b/frontend/src/features/viewport-replay/components/ReplayLayer.tsx
index bed2cbb..cd8cafd 100644
--- a/frontend/src/features/viewport-replay/components/ReplayLayer.tsx
+++ b/frontend/src/features/viewport-replay/components/ReplayLayer.tsx
@@ -64,16 +64,19 @@ export function useReplayLayer({ zoom, onLayersUpdate }: ReplayLayerProps) {
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 iconData: VesselIconData[] = positions.map((p) => {
+ const displaySog = p.speed > 0 ? p.speed : 2
+ return {
+ mmsi: p.vesselId,
+ position: [p.lon, p.lat],
+ angle: p.heading,
+ icon: getIconKey(p.shipKindCode, displaySog),
+ size: getIconSize(zoom, p.shipKindCode, displaySog),
+ shipNm: p.shipName,
+ shipKindCode: p.shipKindCode,
+ sog: p.speed,
+ }
+ })
const layers: Layer[] = [
new TripsLayer({
diff --git a/frontend/src/pages/ApiExplorer.tsx b/frontend/src/pages/ApiExplorer.tsx
index b7fd216..aa6e90b 100644
--- a/frontend/src/pages/ApiExplorer.tsx
+++ b/frontend/src/pages/ApiExplorer.tsx
@@ -1,7 +1,8 @@
-import { useState, useCallback, useRef } from 'react'
+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 {
@@ -16,14 +17,79 @@ import {
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[] = []
+ 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('positions')
@@ -69,6 +135,11 @@ export default function ApiExplorer() {
selectVessel(null)
}, [selectVessel])
+ // Raw Data 훅
+ const positionData = usePositionRawData()
+ const trackData = useTrackRawData()
+ const replayData = useReplayRawData()
+
// 모드별 Deck.gl 레이어
const deckLayers: Layer[] =
mode === 'positions' ? positionLayers :
@@ -80,7 +151,7 @@ export default function ApiExplorer() {
{/* Sidebar */}
-
+
{t('explorer.title')}
@@ -131,6 +202,29 @@ export default function ApiExplorer() {
)}
+
+ {/* Response Data — Swagger Response 스타일 */}
+ {mode === 'positions' && (
+
+ )}
+ {mode === 'vessel' && (
+
+ )}
+ {mode === 'replay' && (
+
+ )}