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' && ( + + )}