From f41463d0f29543728bd03871db3c8113d3e1c20a Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 15:59:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=AD=EC=A0=81/=EB=A6=AC=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20=EC=84=A0=EC=A2=85=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=ED=91=9C=EC=8B=9C=20=EC=88=98=EC=A0=95=20+=20Raw?= =?UTF-8?q?=20Data=20=ED=8C=A8=EB=84=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 속도 0일 때 이동 아이콘으로 표시하여 선종별 색상/형태 구분 - ApiExplorer 좌측 패널에 Swagger 스타일 JSON 응답 데이터 표시 - 3모드(최근위치/항적/리플레이) 각각 선택 데이터 실시간 표시 Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/RawDataPanel.tsx | 60 ++++++++++++ .../components/VesselTracksLayer.tsx | 25 +++-- .../components/ReplayLayer.tsx | 23 +++-- frontend/src/pages/ApiExplorer.tsx | 98 ++++++++++++++++++- 4 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/RawDataPanel.tsx 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' && ( + + )}
-- 2.45.2