Merge pull request 'feat: 항적/리플레이 선종 아이콘 + Raw Data 패널' (#66) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m41s

This commit is contained in:
htlee 2026-02-20 16:00:18 +09:00
커밋 a7b9e76d51
4개의 변경된 파일184개의 추가작업 그리고 22개의 파일을 삭제

파일 보기

@ -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) => {
mmsi: p.vesselId, const displaySog = p.speed > 0 ? p.speed : 2
position: [p.lon, p.lat], return {
angle: p.heading, mmsi: p.vesselId,
icon: getIconKey(p.shipKindCode, p.speed), position: [p.lon, p.lat],
size: getIconSize(zoom, p.shipKindCode, p.speed), angle: p.heading,
shipNm: p.shipName, icon: getIconKey(p.shipKindCode, displaySog),
shipKindCode: p.shipKindCode, size: getIconSize(zoom, p.shipKindCode, displaySog),
sog: p.speed, shipNm: p.shipName,
})) shipKindCode: p.shipKindCode,
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) => {
mmsi: p.vesselId, const displaySog = p.speed > 0 ? p.speed : 2
position: [p.lon, p.lat], return {
angle: p.heading, mmsi: p.vesselId,
icon: getIconKey(p.shipKindCode, p.speed), position: [p.lon, p.lat],
size: getIconSize(zoom, p.shipKindCode, p.speed), angle: p.heading,
shipNm: p.shipName, icon: getIconKey(p.shipKindCode, displaySog),
shipKindCode: p.shipKindCode, size: getIconSize(zoom, p.shipKindCode, displaySog),
sog: p.speed, shipNm: p.shipName,
})) shipKindCode: p.shipKindCode,
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>