feat: 항적/리플레이 선종 아이콘 + Raw Data 패널 #66
60
frontend/src/components/RawDataPanel.tsx
Normal file
60
frontend/src/components/RawDataPanel.tsx
Normal file
@ -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 (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border border-border">
|
||||
{/* 헤더 */}
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="flex items-center justify-between px-3 py-2 text-xs font-medium text-muted hover:bg-surface-hover transition"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] rounded bg-primary/10 text-primary px-1 py-0.5">
|
||||
JSON
|
||||
</span>
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-[10px]">{collapsed ? '▶' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* 본문 */}
|
||||
{!collapsed && (
|
||||
<div className="flex-1 overflow-auto border-t border-border bg-surface-hover/30">
|
||||
{hasData ? (
|
||||
<pre className="whitespace-pre-wrap break-all p-2 text-[11px] leading-relaxed font-mono text-foreground/80 select-all">
|
||||
{jsonStr}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="p-3 text-center text-xs text-muted">{emptyText}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 }),
|
||||
|
||||
@ -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<TripsData>({
|
||||
|
||||
@ -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<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')
|
||||
@ -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() {
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="relative flex">
|
||||
<Sidebar width={320}>
|
||||
<Sidebar width={340}>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">{t('explorer.title')}</h2>
|
||||
|
||||
@ -131,6 +202,29 @@ export default function ApiExplorer() {
|
||||
<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>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user