Merge pull request 'feat: 항적/리플레이 선종 아이콘 + Raw Data 패널' (#66) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m41s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m41s
This commit is contained in:
커밋
a7b9e76d51
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. 현재 위치 아이콘 레이어
|
// 2. 현재 위치 아이콘 레이어
|
||||||
|
// 항적 데이터의 speeds가 0 (백엔드 WKT→좌표 변환 시 속도 미포함)
|
||||||
|
// 항적이 있으면 운항 중이므로 이동 아이콘 표시
|
||||||
const positions = getCurrentPositions()
|
const positions = getCurrentPositions()
|
||||||
const iconData: VesselIconData[] = positions.map((p) => ({
|
const iconData: VesselIconData[] = positions.map((p) => {
|
||||||
|
const displaySog = p.speed > 0 ? p.speed : 2
|
||||||
|
return {
|
||||||
mmsi: p.vesselId,
|
mmsi: p.vesselId,
|
||||||
position: [p.lon, p.lat],
|
position: [p.lon, p.lat],
|
||||||
angle: p.heading,
|
angle: p.heading,
|
||||||
icon: getIconKey(p.shipKindCode, p.speed),
|
icon: getIconKey(p.shipKindCode, displaySog),
|
||||||
size: getIconSize(zoom, p.shipKindCode, p.speed),
|
size: getIconSize(zoom, p.shipKindCode, displaySog),
|
||||||
shipNm: p.shipName,
|
shipNm: p.shipName,
|
||||||
shipKindCode: p.shipKindCode,
|
shipKindCode: p.shipKindCode,
|
||||||
sog: p.speed,
|
sog: p.speed,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
createTrackPathLayer({ id: 'vessel-track-path', data: pathData }),
|
createTrackPathLayer({ id: 'vessel-track-path', data: pathData }),
|
||||||
|
|||||||
@ -64,16 +64,19 @@ export function useReplayLayer({ zoom, onLayersUpdate }: ReplayLayerProps) {
|
|||||||
const relativeTime = currentTime - startTime
|
const relativeTime = currentTime - startTime
|
||||||
|
|
||||||
const positions = useAnimationStore.getState().getCurrentVesselPositions()
|
const positions = useAnimationStore.getState().getCurrentVesselPositions()
|
||||||
const iconData: VesselIconData[] = positions.map((p) => ({
|
const iconData: VesselIconData[] = positions.map((p) => {
|
||||||
|
const displaySog = p.speed > 0 ? p.speed : 2
|
||||||
|
return {
|
||||||
mmsi: p.vesselId,
|
mmsi: p.vesselId,
|
||||||
position: [p.lon, p.lat],
|
position: [p.lon, p.lat],
|
||||||
angle: p.heading,
|
angle: p.heading,
|
||||||
icon: getIconKey(p.shipKindCode, p.speed),
|
icon: getIconKey(p.shipKindCode, displaySog),
|
||||||
size: getIconSize(zoom, p.shipKindCode, p.speed),
|
size: getIconSize(zoom, p.shipKindCode, displaySog),
|
||||||
shipNm: p.shipName,
|
shipNm: p.shipName,
|
||||||
shipKindCode: p.shipKindCode,
|
shipKindCode: p.shipKindCode,
|
||||||
sog: p.speed,
|
sog: p.speed,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const layers: Layer[] = [
|
const layers: Layer[] = [
|
||||||
new TripsLayer<TripsData>({
|
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 { useI18n } from '../hooks/useI18n.ts'
|
||||||
import MapContainer from '../components/map/MapContainer.tsx'
|
import MapContainer from '../components/map/MapContainer.tsx'
|
||||||
import Sidebar from '../components/layout/Sidebar.tsx'
|
import Sidebar from '../components/layout/Sidebar.tsx'
|
||||||
|
import RawDataPanel from '../components/RawDataPanel.tsx'
|
||||||
import type maplibregl from 'maplibre-gl'
|
import type maplibregl from 'maplibre-gl'
|
||||||
import type { Layer } from '@deck.gl/core'
|
import type { Layer } from '@deck.gl/core'
|
||||||
import {
|
import {
|
||||||
@ -16,14 +17,79 @@ import {
|
|||||||
TrackQueryPanel,
|
TrackQueryPanel,
|
||||||
TrackInfoPanel,
|
TrackInfoPanel,
|
||||||
} from '../features/vessel-tracks'
|
} from '../features/vessel-tracks'
|
||||||
|
import { useTrackStore } from '../features/vessel-tracks/stores/trackStore'
|
||||||
import {
|
import {
|
||||||
useReplayLayer,
|
useReplayLayer,
|
||||||
|
useReplayStore,
|
||||||
|
useMergedTrackStore,
|
||||||
ReplaySetupPanel,
|
ReplaySetupPanel,
|
||||||
ReplayControlPanel,
|
ReplayControlPanel,
|
||||||
} from '../features/viewport-replay'
|
} from '../features/viewport-replay'
|
||||||
|
|
||||||
type ApiMode = 'positions' | 'vessel' | '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() {
|
export default function ApiExplorer() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [mode, setMode] = useState<ApiMode>('positions')
|
const [mode, setMode] = useState<ApiMode>('positions')
|
||||||
@ -69,6 +135,11 @@ export default function ApiExplorer() {
|
|||||||
selectVessel(null)
|
selectVessel(null)
|
||||||
}, [selectVessel])
|
}, [selectVessel])
|
||||||
|
|
||||||
|
// Raw Data 훅
|
||||||
|
const positionData = usePositionRawData()
|
||||||
|
const trackData = useTrackRawData()
|
||||||
|
const replayData = useReplayRawData()
|
||||||
|
|
||||||
// 모드별 Deck.gl 레이어
|
// 모드별 Deck.gl 레이어
|
||||||
const deckLayers: Layer[] =
|
const deckLayers: Layer[] =
|
||||||
mode === 'positions' ? positionLayers :
|
mode === 'positions' ? positionLayers :
|
||||||
@ -80,7 +151,7 @@ export default function ApiExplorer() {
|
|||||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<Sidebar width={320}>
|
<Sidebar width={340}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-bold">{t('explorer.title')}</h2>
|
<h2 className="text-lg font-bold">{t('explorer.title')}</h2>
|
||||||
|
|
||||||
@ -131,6 +202,29 @@ export default function ApiExplorer() {
|
|||||||
<ReplaySetupPanel map={mapRef.current} />
|
<ReplaySetupPanel map={mapRef.current} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user