diff --git a/apps/web/src/features/trackReplay/lib/csvExport.ts b/apps/web/src/features/trackReplay/lib/csvExport.ts new file mode 100644 index 0000000..be021cd --- /dev/null +++ b/apps/web/src/features/trackReplay/lib/csvExport.ts @@ -0,0 +1,141 @@ +import { DISPLAY_TZ } from '../../../shared/lib/datetime'; +import { haversineNm } from '../../../shared/lib/geo/haversineNm'; +import type { ProcessedTrack, TrackQueryContext } from '../model/track.types'; + +const BOM = '\uFEFF'; + +function escCsv(value: string | number | undefined | null): string { + if (value == null) return ''; + const s = String(value); + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +function fmtTimestamp(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return ''; + return new Date(ms).toLocaleString('sv-SE', { timeZone: DISPLAY_TZ }); +} + +/** 두 포인트 간 거리(NM)·시간차(h)로 속력(knots) 계산 */ +function calcSpeedKnots(track: ProcessedTrack, index: number): number { + if (index <= 0) return 0; + const [lon1, lat1] = track.geometry[index - 1]; + const [lon2, lat2] = track.geometry[index]; + const dtMs = track.timestampsMs[index] - track.timestampsMs[index - 1]; + if (dtMs <= 0) return 0; + const distNm = haversineNm(lat1, lon1, lat2, lon2); + const hours = dtMs / 3_600_000; + return Math.round((distNm / hours) * 100) / 100; +} + +/** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */ +export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string { + const header = ['mmsi', 'longitude', 'latitude', 'timestamp', 'timestampMs', 'speedKnots']; + const rows: string[] = [header.join(',')]; + + const mmsi = ctx?.mmsi ?? ''; + + for (const track of tracks) { + const trackMmsi = mmsi || track.targetId; + for (let i = 0; i < track.geometry.length; i++) { + rows.push( + [ + escCsv(trackMmsi), + escCsv(track.geometry[i][0]), + escCsv(track.geometry[i][1]), + escCsv(fmtTimestamp(track.timestampsMs[i])), + escCsv(track.timestampsMs[i]), + escCsv(calcSpeedKnots(track, i)), + ].join(','), + ); + } + } + + return BOM + rows.join('\n'); +} + +/** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */ +export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string { + const header = [ + 'mmsi', + 'shipName', + 'vesselType', + 'ownerCn', + 'ownerRoman', + 'permitNo', + 'pairPermitNo', + 'ton', + 'callSign', + 'workSeaArea', + 'nationalCode', + 'totalDistanceNm', + 'avgSpeed', + 'maxSpeed', + 'pointCount', + 'startTime', + 'endTime', + 'chnPrmShipName', + 'chnPrmVesselType', + 'chnPrmCallsign', + 'chnPrmImo', + ]; + const rows: string[] = [header.join(',')]; + + for (const track of tracks) { + const firstTs = track.timestampsMs[0] ?? 0; + const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0; + const info = track.chnPrmShipInfo; + + rows.push( + [ + escCsv(ctx?.mmsi ?? track.targetId), + escCsv(track.shipName), + escCsv(ctx?.vesselType ?? ''), + escCsv(ctx?.ownerCn), + escCsv(ctx?.ownerRoman), + escCsv(ctx?.permitNo), + escCsv(ctx?.pairPermitNo), + escCsv(ctx?.ton), + escCsv(ctx?.callSign), + escCsv(ctx?.workSeaArea), + escCsv(track.nationalCode), + escCsv(track.stats.totalDistanceNm), + escCsv(track.stats.avgSpeed), + escCsv(track.stats.maxSpeed), + escCsv(track.stats.pointCount), + escCsv(fmtTimestamp(firstTs)), + escCsv(fmtTimestamp(lastTs)), + escCsv(info?.name), + escCsv(info?.vesselType), + escCsv(info?.callsign), + escCsv(info?.imo), + ].join(','), + ); + } + + return BOM + rows.join('\n'); +} + +function downloadCsv(csvContent: string, filename: string): void { + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): void { + const now = new Date(); + const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); + + downloadCsv(buildDynamicCsv(tracks, ctx), `track-points-${ts}.csv`); + setTimeout(() => { + downloadCsv(buildStaticCsv(tracks, ctx), `track-vessel-${ts}.csv`); + }, 100); +} diff --git a/apps/web/src/features/trackReplay/model/track.types.ts b/apps/web/src/features/trackReplay/model/track.types.ts index 83c5746..da36d20 100644 --- a/apps/web/src/features/trackReplay/model/track.types.ts +++ b/apps/web/src/features/trackReplay/model/track.types.ts @@ -7,6 +7,13 @@ export interface TrackStats { pointCount: number; } +export interface ChnPrmShipInfo { + name?: string; + vesselType?: string; + callsign?: string; + imo?: number; +} + export interface ProcessedTrack { vesselId: string; targetId: string; @@ -18,6 +25,7 @@ export interface ProcessedTrack { timestampsMs: number[]; speeds: number[]; stats: TrackStats; + chnPrmShipInfo?: ChnPrmShipInfo; } export interface CurrentVesselPosition { @@ -38,6 +46,24 @@ export interface TrackQueryRequest { minutes: number; } +export interface TrackQueryContext { + mmsi: number; + startTimeIso: string; + endTimeIso: string; + shipNameHint?: string; + shipKindCodeHint?: string; + nationalCodeHint?: string; + isPermitted: boolean; + vesselType?: string; + ownerCn?: string | null; + ownerRoman?: string | null; + permitNo?: string; + pairPermitNo?: string | null; + ton?: number | null; + callSign?: string; + workSeaArea?: string; +} + export interface ReplayStreamQueryRequest { startTime: string; endTime: string; diff --git a/apps/web/src/features/trackReplay/services/trackQueryService.ts b/apps/web/src/features/trackReplay/services/trackQueryService.ts index d1e93eb..1b1ac30 100644 --- a/apps/web/src/features/trackReplay/services/trackQueryService.ts +++ b/apps/web/src/features/trackReplay/services/trackQueryService.ts @@ -9,6 +9,16 @@ type QueryTrackByMmsiParams = { isPermitted?: boolean; }; +export type QueryTrackByDateRangeParams = { + mmsi: number; + startTimeIso: string; + endTimeIso: string; + shipNameHint?: string; + shipKindCodeHint?: string; + nationalCodeHint?: string; + isPermitted?: boolean; +}; + type V2TrackResponse = { vesselId?: string; targetId?: string; @@ -100,23 +110,26 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] { maxSpeed: row.maxSpeed || 0, pointCount: row.pointCount || geometry.length, }, + chnPrmShipInfo: row.chnPrmShipInfo ? { ...row.chnPrmShipInfo } : undefined, }); } return out; } -async function queryV2Track(params: QueryTrackByMmsiParams): Promise { +async function fetchV2Tracks( + startTimeIso: string, + endTimeIso: string, + mmsi: number, + isPermitted: boolean, +): Promise { const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim(); - const end = new Date(); - const start = new Date(end.getTime() - params.minutes * 60_000); - const requestBody = { - startTime: start.toISOString(), - endTime: end.toISOString(), - vessels: [String(params.mmsi)], - includeChnPrmShip: params.isPermitted ?? false, + startTime: startTimeIso, + endTime: endTimeIso, + vessels: [String(mmsi)], + includeChnPrmShip: isPermitted, }; const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`; @@ -141,5 +154,11 @@ async function queryV2Track(params: QueryTrackByMmsiParams): Promise { - return queryV2Track(params); + const end = new Date(); + const start = new Date(end.getTime() - params.minutes * 60_000); + return fetchV2Tracks(start.toISOString(), end.toISOString(), params.mmsi, params.isPermitted ?? false); +} + +export async function queryTrackByDateRange(params: QueryTrackByDateRangeParams): Promise { + return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsi, params.isPermitted ?? false); } diff --git a/apps/web/src/features/trackReplay/stores/trackQueryStore.ts b/apps/web/src/features/trackReplay/stores/trackQueryStore.ts index 0a29cd7..34e100e 100644 --- a/apps/web/src/features/trackReplay/stores/trackQueryStore.ts +++ b/apps/web/src/features/trackReplay/stores/trackQueryStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { getTracksTimeRange } from '../lib/adapters'; -import type { ProcessedTrack } from '../model/track.types'; +import type { ProcessedTrack, TrackQueryContext } from '../model/track.types'; +import { queryTrackByDateRange } from '../services/trackQueryService'; import { useTrackPlaybackStore } from './trackPlaybackStore'; export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error'; @@ -14,16 +15,18 @@ interface TrackQueryState { queryState: TrackQueryStatus; renderEpoch: number; lastQueryKey: string | null; + queryContext: TrackQueryContext | null; showPoints: boolean; showVirtualShip: boolean; showLabels: boolean; showTrail: boolean; hideLiveShips: boolean; - beginQuery: (queryKey: string) => void; + beginQuery: (queryKey: string, context?: TrackQueryContext) => void; applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void; applyQueryError: (error: string, queryKey?: string | null) => void; closeQuery: () => void; + requery: (startTimeIso: string, endTimeIso: string) => Promise; setTracks: (tracks: ProcessedTrack[]) => void; clearTracks: () => void; @@ -49,13 +52,14 @@ export const useTrackQueryStore = create()((set, get) => ({ queryState: 'idle', renderEpoch: 0, lastQueryKey: null, + queryContext: null, showPoints: true, showVirtualShip: true, showLabels: true, showTrail: true, hideLiveShips: false, - beginQuery: (queryKey: string) => { + beginQuery: (queryKey: string, context?: TrackQueryContext) => { useTrackPlaybackStore.getState().reset(); set((state) => ({ tracks: [], @@ -67,13 +71,13 @@ export const useTrackQueryStore = create()((set, get) => ({ renderEpoch: state.renderEpoch + 1, lastQueryKey: queryKey, hideLiveShips: false, + queryContext: context ?? state.queryContext, })); }, applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => { const currentQueryKey = get().lastQueryKey; if (queryKey != null && queryKey !== currentQueryKey) { - // Ignore stale async responses from an older query. return; } @@ -113,7 +117,6 @@ export const useTrackQueryStore = create()((set, get) => ({ applyQueryError: (error: string, queryKey?: string | null) => { const currentQueryKey = get().lastQueryKey; if (queryKey != null && queryKey !== currentQueryKey) { - // Ignore stale async errors from an older query. return; } @@ -142,10 +145,41 @@ export const useTrackQueryStore = create()((set, get) => ({ queryState: 'idle', renderEpoch: state.renderEpoch + 1, lastQueryKey: null, + queryContext: null, hideLiveShips: false, })); }, + requery: async (startTimeIso: string, endTimeIso: string) => { + const ctx = get().queryContext; + if (!ctx) return; + + const queryKey = `requery:${ctx.mmsi}:${Date.now()}`; + const updatedContext: TrackQueryContext = { ...ctx, startTimeIso, endTimeIso }; + + get().beginQuery(queryKey, updatedContext); + + try { + const tracks = await queryTrackByDateRange({ + mmsi: updatedContext.mmsi, + startTimeIso: updatedContext.startTimeIso, + endTimeIso: updatedContext.endTimeIso, + shipNameHint: updatedContext.shipNameHint, + shipKindCodeHint: updatedContext.shipKindCodeHint, + nationalCodeHint: updatedContext.nationalCodeHint, + isPermitted: updatedContext.isPermitted, + }); + + if (tracks.length > 0) { + get().applyTracksSuccess(tracks, queryKey); + } else { + get().applyQueryError('항적 데이터가 없습니다.', queryKey); + } + } catch (e) { + get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey); + } + }, + setTracks: (tracks: ProcessedTrack[]) => { get().applyTracksSuccess(tracks, get().lastQueryKey); }, @@ -200,6 +234,7 @@ export const useTrackQueryStore = create()((set, get) => ({ queryState: 'idle', renderEpoch: state.renderEpoch + 1, lastQueryKey: null, + queryContext: null, showPoints: true, showVirtualShip: true, showLabels: true, diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 086a3cd..c56c551 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -22,6 +22,7 @@ import type { ShipImageInfo } from "../../entities/shipImage/model/types"; import ShipImageModal from "../../widgets/shipImage/ShipImageModal"; import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService"; import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore"; +import type { TrackQueryContext } from "../../features/trackReplay/model/track.types"; import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel"; import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling"; import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay"; @@ -134,11 +135,33 @@ export function DashboardPage() { const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { const trackStore = useTrackQueryStore.getState(); const queryKey = `${mmsi}:${minutes}:${Date.now()}`; - trackStore.beginQuery(queryKey); + + const target = targets.find((item) => item.mmsi === mmsi); + const isPermitted = legacyHits.has(mmsi); + + const endDate = new Date(); + const startDate = new Date(endDate.getTime() - minutes * 60_000); + const legacy = legacyHits.get(mmsi); + const context: TrackQueryContext = { + mmsi, + startTimeIso: startDate.toISOString(), + endTimeIso: endDate.toISOString(), + shipNameHint: target?.name, + shipKindCodeHint: target?.shipKindCode, + nationalCodeHint: target?.nationalCode, + isPermitted, + vesselType: legacy?.shipCode, + ownerCn: legacy?.ownerCn, + ownerRoman: legacy?.ownerRoman, + permitNo: legacy?.permitNo, + pairPermitNo: legacy?.pairPermitNo, + ton: legacy?.ton, + callSign: legacy?.callSign, + workSeaArea: legacy?.workSeaArea, + }; + trackStore.beginQuery(queryKey, context); try { - const target = targets.find((item) => item.mmsi === mmsi); - const isPermitted = legacyHits.has(mmsi); const tracks = await queryTrackByMmsi({ mmsi, minutes, diff --git a/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx b/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx index f5c8ca4..8e67983 100644 --- a/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx +++ b/apps/web/src/widgets/trackReplay/GlobalTrackReplayPanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore'; import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore'; +import { exportTrackCsv } from '../../features/trackReplay/lib/csvExport'; function formatDateTime(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return '--'; @@ -11,6 +12,38 @@ function formatDateTime(ms: number): string { )}:${pad(date.getSeconds())}`; } +/** ms → datetime-local input value (KST = UTC+9) */ +function toDateTimeLocalKST(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return ''; + const kstDate = new Date(ms + 9 * 3600_000); + return kstDate.toISOString().slice(0, 16); +} + +/** datetime-local value (KST) → ISO string */ +function fromDateTimeLocalKST(value: string): string { + return `${value}:00+09:00`; +} + +const inputStyle: React.CSSProperties = { + flex: 1, + fontSize: 11, + padding: '3px 6px', + borderRadius: 4, + border: '1px solid rgba(148,163,184,0.35)', + background: 'rgba(30,41,59,0.8)', + color: '#e2e8f0', + colorScheme: 'dark', +}; + +const btnBase: React.CSSProperties = { + padding: '6px 10px', + borderRadius: 6, + border: '1px solid rgba(148,163,184,0.45)', + background: 'rgba(30,41,59,0.8)', + color: '#e2e8f0', + cursor: 'pointer', +}; + export function GlobalTrackReplayPanel() { const PANEL_WIDTH = 420; const PANEL_MARGIN = 12; @@ -50,30 +83,52 @@ export function GlobalTrackReplayPanel() { const tracks = useTrackQueryStore((state) => state.tracks); const isLoading = useTrackQueryStore((state) => state.isLoading); const error = useTrackQueryStore((state) => state.error); + const queryContext = useTrackQueryStore((state) => state.queryContext); const showPoints = useTrackQueryStore((state) => state.showPoints); - const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip); const showLabels = useTrackQueryStore((state) => state.showLabels); const showTrail = useTrackQueryStore((state) => state.showTrail); const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips); const setShowPoints = useTrackQueryStore((state) => state.setShowPoints); - const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip); const setShowLabels = useTrackQueryStore((state) => state.setShowLabels); const setShowTrail = useTrackQueryStore((state) => state.setShowTrail); const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips); const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery); + const requery = useTrackQueryStore((state) => state.requery); const isPlaying = useTrackPlaybackStore((state) => state.isPlaying); const currentTime = useTrackPlaybackStore((state) => state.currentTime); const startTime = useTrackPlaybackStore((state) => state.startTime); const endTime = useTrackPlaybackStore((state) => state.endTime); const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed); - const loop = useTrackPlaybackStore((state) => state.loop); const play = useTrackPlaybackStore((state) => state.play); const pause = useTrackPlaybackStore((state) => state.pause); const stop = useTrackPlaybackStore((state) => state.stop); const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime); const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed); - const toggleLoop = useTrackPlaybackStore((state) => state.toggleLoop); + + const timeSyncKey = `${startTime}:${endTime}`; + const [editState, setEditState] = useState({ start: '', end: '', syncKey: '' }); + if (editState.syncKey !== timeSyncKey && startTime > 0 && endTime > 0) { + setEditState({ + start: toDateTimeLocalKST(startTime), + end: toDateTimeLocalKST(endTime), + syncKey: timeSyncKey, + }); + } + const editStartTime = editState.start; + const editEndTime = editState.end; + const setEditStartTime = (v: string) => setEditState((prev) => ({ ...prev, start: v })); + const setEditEndTime = (v: string) => setEditState((prev) => ({ ...prev, end: v })); + + const handleRequery = useCallback(() => { + if (!editStartTime || !editEndTime) return; + requery(fromDateTimeLocalKST(editStartTime), fromDateTimeLocalKST(editEndTime)); + }, [editStartTime, editEndTime, requery]); + + const handleExportCsv = useCallback(() => { + if (tracks.length === 0) return; + exportTrackCsv(tracks, queryContext); + }, [tracks, queryContext]); const progress = useMemo(() => { if (endTime <= startTime) return 0; @@ -161,6 +216,7 @@ export function GlobalTrackReplayPanel() { boxShadow: '0 8px 24px rgba(2,6,23,0.45)', }} > + {/* Header */}
항적 조회 중...
: null} -
- 선박 {tracks.length}척 · {formatDateTime(startTime)} ~ {formatDateTime(endTime)} + {/* Vessel count */} +
+ 선박 {tracks.length}척
+ {/* Date range editing */} +
+
+ + setEditStartTime(e.target.value)} + disabled={isLoading || !queryContext} + style={inputStyle} + /> +
+
+ + setEditEndTime(e.target.value)} + disabled={isLoading || !queryContext} + style={inputStyle} + /> +
+
+ + +
+
+ + {/* Playback controls */}
@@ -222,14 +333,7 @@ export function GlobalTrackReplayPanel() { type="button" onClick={() => stop()} disabled={tracks.length === 0} - style={{ - padding: '6px 10px', - borderRadius: 6, - border: '1px solid rgba(148,163,184,0.45)', - background: 'rgba(30,41,59,0.8)', - color: '#e2e8f0', - cursor: 'pointer', - }} + style={btnBase} > 정지 @@ -256,6 +360,7 @@ export function GlobalTrackReplayPanel() {
+ {/* Timeline slider */}
-
+ {/* Display toggles */} +
- @@ -298,9 +396,6 @@ export function GlobalTrackReplayPanel() { />{' '} 라이브 숨김 -
);