Merge pull request 'feat(trackReplay): 항적 기간 조정/재조회 + CSV 내보내기' (#47) from feature/announcement-popup into develop

Reviewed-on: #47
This commit is contained in:
htlee 2026-02-25 03:08:24 +09:00
커밋 cd30d6d78e
6개의 변경된 파일390개의 추가작업 그리고 51개의 파일을 삭제

파일 보기

@ -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);
}

파일 보기

@ -7,6 +7,13 @@ export interface TrackStats {
pointCount: number; pointCount: number;
} }
export interface ChnPrmShipInfo {
name?: string;
vesselType?: string;
callsign?: string;
imo?: number;
}
export interface ProcessedTrack { export interface ProcessedTrack {
vesselId: string; vesselId: string;
targetId: string; targetId: string;
@ -18,6 +25,7 @@ export interface ProcessedTrack {
timestampsMs: number[]; timestampsMs: number[];
speeds: number[]; speeds: number[];
stats: TrackStats; stats: TrackStats;
chnPrmShipInfo?: ChnPrmShipInfo;
} }
export interface CurrentVesselPosition { export interface CurrentVesselPosition {
@ -38,6 +46,24 @@ export interface TrackQueryRequest {
minutes: number; 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 { export interface ReplayStreamQueryRequest {
startTime: string; startTime: string;
endTime: string; endTime: string;

파일 보기

@ -9,6 +9,16 @@ type QueryTrackByMmsiParams = {
isPermitted?: boolean; isPermitted?: boolean;
}; };
export type QueryTrackByDateRangeParams = {
mmsi: number;
startTimeIso: string;
endTimeIso: string;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
isPermitted?: boolean;
};
type V2TrackResponse = { type V2TrackResponse = {
vesselId?: string; vesselId?: string;
targetId?: string; targetId?: string;
@ -100,23 +110,26 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
maxSpeed: row.maxSpeed || 0, maxSpeed: row.maxSpeed || 0,
pointCount: row.pointCount || geometry.length, pointCount: row.pointCount || geometry.length,
}, },
chnPrmShipInfo: row.chnPrmShipInfo ? { ...row.chnPrmShipInfo } : undefined,
}); });
} }
return out; return out;
} }
async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> { async function fetchV2Tracks(
startTimeIso: string,
endTimeIso: string,
mmsi: number,
isPermitted: boolean,
): Promise<ProcessedTrack[]> {
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim(); 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 = { const requestBody = {
startTime: start.toISOString(), startTime: startTimeIso,
endTime: end.toISOString(), endTime: endTimeIso,
vessels: [String(params.mmsi)], vessels: [String(mmsi)],
includeChnPrmShip: params.isPermitted ?? false, includeChnPrmShip: isPermitted,
}; };
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`; const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
@ -141,5 +154,11 @@ async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTr
} }
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> { export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
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<ProcessedTrack[]> {
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsi, params.isPermitted ?? false);
} }

파일 보기

@ -1,6 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { getTracksTimeRange } from '../lib/adapters'; 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'; import { useTrackPlaybackStore } from './trackPlaybackStore';
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error'; export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
@ -14,16 +15,18 @@ interface TrackQueryState {
queryState: TrackQueryStatus; queryState: TrackQueryStatus;
renderEpoch: number; renderEpoch: number;
lastQueryKey: string | null; lastQueryKey: string | null;
queryContext: TrackQueryContext | null;
showPoints: boolean; showPoints: boolean;
showVirtualShip: boolean; showVirtualShip: boolean;
showLabels: boolean; showLabels: boolean;
showTrail: boolean; showTrail: boolean;
hideLiveShips: boolean; hideLiveShips: boolean;
beginQuery: (queryKey: string) => void; beginQuery: (queryKey: string, context?: TrackQueryContext) => void;
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void; applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
applyQueryError: (error: string, queryKey?: string | null) => void; applyQueryError: (error: string, queryKey?: string | null) => void;
closeQuery: () => void; closeQuery: () => void;
requery: (startTimeIso: string, endTimeIso: string) => Promise<void>;
setTracks: (tracks: ProcessedTrack[]) => void; setTracks: (tracks: ProcessedTrack[]) => void;
clearTracks: () => void; clearTracks: () => void;
@ -49,13 +52,14 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle', queryState: 'idle',
renderEpoch: 0, renderEpoch: 0,
lastQueryKey: null, lastQueryKey: null,
queryContext: null,
showPoints: true, showPoints: true,
showVirtualShip: true, showVirtualShip: true,
showLabels: true, showLabels: true,
showTrail: true, showTrail: true,
hideLiveShips: false, hideLiveShips: false,
beginQuery: (queryKey: string) => { beginQuery: (queryKey: string, context?: TrackQueryContext) => {
useTrackPlaybackStore.getState().reset(); useTrackPlaybackStore.getState().reset();
set((state) => ({ set((state) => ({
tracks: [], tracks: [],
@ -67,13 +71,13 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
renderEpoch: state.renderEpoch + 1, renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey, lastQueryKey: queryKey,
hideLiveShips: false, hideLiveShips: false,
queryContext: context ?? state.queryContext,
})); }));
}, },
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => { applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey; const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) { if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async responses from an older query.
return; return;
} }
@ -113,7 +117,6 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
applyQueryError: (error: string, queryKey?: string | null) => { applyQueryError: (error: string, queryKey?: string | null) => {
const currentQueryKey = get().lastQueryKey; const currentQueryKey = get().lastQueryKey;
if (queryKey != null && queryKey !== currentQueryKey) { if (queryKey != null && queryKey !== currentQueryKey) {
// Ignore stale async errors from an older query.
return; return;
} }
@ -142,10 +145,41 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle', queryState: 'idle',
renderEpoch: state.renderEpoch + 1, renderEpoch: state.renderEpoch + 1,
lastQueryKey: null, lastQueryKey: null,
queryContext: null,
hideLiveShips: false, 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[]) => { setTracks: (tracks: ProcessedTrack[]) => {
get().applyTracksSuccess(tracks, get().lastQueryKey); get().applyTracksSuccess(tracks, get().lastQueryKey);
}, },
@ -200,6 +234,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'idle', queryState: 'idle',
renderEpoch: state.renderEpoch + 1, renderEpoch: state.renderEpoch + 1,
lastQueryKey: null, lastQueryKey: null,
queryContext: null,
showPoints: true, showPoints: true,
showVirtualShip: true, showVirtualShip: true,
showLabels: true, showLabels: true,

파일 보기

@ -22,6 +22,7 @@ import type { ShipImageInfo } from "../../entities/shipImage/model/types";
import ShipImageModal from "../../widgets/shipImage/ShipImageModal"; import ShipImageModal from "../../widgets/shipImage/ShipImageModal";
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService"; import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore"; import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
import type { TrackQueryContext } from "../../features/trackReplay/model/track.types";
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel"; import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling"; import { useWeatherPolling } from "../../features/weatherOverlay/useWeatherPolling";
import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay"; import { useWeatherOverlay } from "../../features/weatherOverlay/useWeatherOverlay";
@ -134,11 +135,33 @@ export function DashboardPage() {
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => { const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
const trackStore = useTrackQueryStore.getState(); const trackStore = useTrackQueryStore.getState();
const queryKey = `${mmsi}:${minutes}:${Date.now()}`; 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 { try {
const target = targets.find((item) => item.mmsi === mmsi);
const isPermitted = legacyHits.has(mmsi);
const tracks = await queryTrackByMmsi({ const tracks = await queryTrackByMmsi({
mmsi, mmsi,
minutes, minutes,

파일 보기

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore'; import { useTrackPlaybackStore, TRACK_PLAYBACK_SPEED_OPTIONS } from '../../features/trackReplay/stores/trackPlaybackStore';
import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore'; import { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
import { exportTrackCsv } from '../../features/trackReplay/lib/csvExport';
function formatDateTime(ms: number): string { function formatDateTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '--'; if (!Number.isFinite(ms) || ms <= 0) return '--';
@ -11,6 +12,38 @@ function formatDateTime(ms: number): string {
)}:${pad(date.getSeconds())}`; )}:${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() { export function GlobalTrackReplayPanel() {
const PANEL_WIDTH = 420; const PANEL_WIDTH = 420;
const PANEL_MARGIN = 12; const PANEL_MARGIN = 12;
@ -50,30 +83,52 @@ export function GlobalTrackReplayPanel() {
const tracks = useTrackQueryStore((state) => state.tracks); const tracks = useTrackQueryStore((state) => state.tracks);
const isLoading = useTrackQueryStore((state) => state.isLoading); const isLoading = useTrackQueryStore((state) => state.isLoading);
const error = useTrackQueryStore((state) => state.error); const error = useTrackQueryStore((state) => state.error);
const queryContext = useTrackQueryStore((state) => state.queryContext);
const showPoints = useTrackQueryStore((state) => state.showPoints); const showPoints = useTrackQueryStore((state) => state.showPoints);
const showVirtualShip = useTrackQueryStore((state) => state.showVirtualShip);
const showLabels = useTrackQueryStore((state) => state.showLabels); const showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail); const showTrail = useTrackQueryStore((state) => state.showTrail);
const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips); const hideLiveShips = useTrackQueryStore((state) => state.hideLiveShips);
const setShowPoints = useTrackQueryStore((state) => state.setShowPoints); const setShowPoints = useTrackQueryStore((state) => state.setShowPoints);
const setShowVirtualShip = useTrackQueryStore((state) => state.setShowVirtualShip);
const setShowLabels = useTrackQueryStore((state) => state.setShowLabels); const setShowLabels = useTrackQueryStore((state) => state.setShowLabels);
const setShowTrail = useTrackQueryStore((state) => state.setShowTrail); const setShowTrail = useTrackQueryStore((state) => state.setShowTrail);
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips); const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery); const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
const requery = useTrackQueryStore((state) => state.requery);
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying); const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
const currentTime = useTrackPlaybackStore((state) => state.currentTime); const currentTime = useTrackPlaybackStore((state) => state.currentTime);
const startTime = useTrackPlaybackStore((state) => state.startTime); const startTime = useTrackPlaybackStore((state) => state.startTime);
const endTime = useTrackPlaybackStore((state) => state.endTime); const endTime = useTrackPlaybackStore((state) => state.endTime);
const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed); const playbackSpeed = useTrackPlaybackStore((state) => state.playbackSpeed);
const loop = useTrackPlaybackStore((state) => state.loop);
const play = useTrackPlaybackStore((state) => state.play); const play = useTrackPlaybackStore((state) => state.play);
const pause = useTrackPlaybackStore((state) => state.pause); const pause = useTrackPlaybackStore((state) => state.pause);
const stop = useTrackPlaybackStore((state) => state.stop); const stop = useTrackPlaybackStore((state) => state.stop);
const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime); const setCurrentTime = useTrackPlaybackStore((state) => state.setCurrentTime);
const setPlaybackSpeed = useTrackPlaybackStore((state) => state.setPlaybackSpeed); 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(() => { const progress = useMemo(() => {
if (endTime <= startTime) return 0; if (endTime <= startTime) return 0;
@ -161,6 +216,7 @@ export function GlobalTrackReplayPanel() {
boxShadow: '0 8px 24px rgba(2,6,23,0.45)', boxShadow: '0 8px 24px rgba(2,6,23,0.45)',
}} }}
> >
{/* Header */}
<div <div
onPointerDown={handleHeaderPointerDown} onPointerDown={handleHeaderPointerDown}
style={{ style={{
@ -198,23 +254,78 @@ export function GlobalTrackReplayPanel() {
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null} {isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}> {/* Vessel count */}
{tracks.length} · {formatDateTime(startTime)} ~ {formatDateTime(endTime)} <div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}>
{tracks.length}
</div> </div>
{/* Date range editing */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input
type="datetime-local"
value={editStartTime}
onChange={(e) => setEditStartTime(e.target.value)}
disabled={isLoading || !queryContext}
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input
type="datetime-local"
value={editEndTime}
onChange={(e) => setEditEndTime(e.target.value)}
disabled={isLoading || !queryContext}
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
onClick={handleRequery}
disabled={isLoading || !queryContext || !editStartTime || !editEndTime}
style={{
flex: 1,
padding: '5px 8px',
fontSize: 11,
borderRadius: 6,
border: '1px solid rgba(96,165,250,0.5)',
background: 'rgba(37,99,235,0.25)',
color: '#93c5fd',
cursor: 'pointer',
}}
>
</button>
<button
type="button"
onClick={handleExportCsv}
disabled={isLoading || tracks.length === 0}
style={{
flex: 1,
padding: '5px 8px',
fontSize: 11,
borderRadius: 6,
border: '1px solid rgba(74,222,128,0.5)',
background: 'rgba(22,163,74,0.2)',
color: '#86efac',
cursor: 'pointer',
}}
>
CSV
</button>
</div>
</div>
{/* Playback controls */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<button <button
type="button" type="button"
onClick={() => (isPlaying ? pause() : play())} onClick={() => (isPlaying ? pause() : play())}
disabled={tracks.length === 0} disabled={tracks.length === 0}
style={{ style={btnBase}
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',
}}
> >
{isPlaying ? '일시정지' : '재생'} {isPlaying ? '일시정지' : '재생'}
</button> </button>
@ -222,14 +333,7 @@ export function GlobalTrackReplayPanel() {
type="button" type="button"
onClick={() => stop()} onClick={() => stop()}
disabled={tracks.length === 0} disabled={tracks.length === 0}
style={{ style={btnBase}
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',
}}
> >
</button> </button>
@ -256,6 +360,7 @@ export function GlobalTrackReplayPanel() {
</label> </label>
</div> </div>
{/* Timeline slider */}
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<input <input
type="range" type="range"
@ -272,18 +377,11 @@ export function GlobalTrackReplayPanel() {
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6, fontSize: 12 }}> {/* Display toggles */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
<label> <label>
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> <input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} />
</label> </label>
<label>
<input
type="checkbox"
checked={showVirtualShip}
onChange={(event) => setShowVirtualShip(event.target.checked)}
/>{' '}
</label>
<label> <label>
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> <input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} />
</label> </label>
@ -298,9 +396,6 @@ export function GlobalTrackReplayPanel() {
/>{' '} />{' '}
</label> </label>
<label>
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} />
</label>
</div> </div>
</div> </div>
); );