Merge pull request 'feat(trackReplay): 항적 기간 조정/재조회 + CSV 내보내기' (#47) from feature/announcement-popup into develop
Reviewed-on: #47
This commit is contained in:
커밋
cd30d6d78e
141
apps/web/src/features/trackReplay/lib/csvExport.ts
Normal file
141
apps/web/src/features/trackReplay/lib/csvExport.ts
Normal file
@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -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<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 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<ProcessedTr
|
||||
}
|
||||
|
||||
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 { 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<void>;
|
||||
|
||||
setTracks: (tracks: ProcessedTrack[]) => void;
|
||||
clearTracks: () => void;
|
||||
@ -49,13 +52,14 @@ export const useTrackQueryStore = create<TrackQueryState>()((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<TrackQueryState>()((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<TrackQueryState>()((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<TrackQueryState>()((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<TrackQueryState>()((set, get) => ({
|
||||
queryState: 'idle',
|
||||
renderEpoch: state.renderEpoch + 1,
|
||||
lastQueryKey: null,
|
||||
queryContext: null,
|
||||
showPoints: true,
|
||||
showVirtualShip: true,
|
||||
showLabels: true,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 */}
|
||||
<div
|
||||
onPointerDown={handleHeaderPointerDown}
|
||||
style={{
|
||||
@ -198,23 +254,78 @@ export function GlobalTrackReplayPanel() {
|
||||
|
||||
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}>항적 조회 중...</div> : null}
|
||||
|
||||
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 8 }}>
|
||||
선박 {tracks.length}척 · {formatDateTime(startTime)} ~ {formatDateTime(endTime)}
|
||||
{/* Vessel count */}
|
||||
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}>
|
||||
선박 {tracks.length}척
|
||||
</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 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isPlaying ? pause() : play())}
|
||||
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}
|
||||
>
|
||||
{isPlaying ? '일시정지' : '재생'}
|
||||
</button>
|
||||
@ -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}
|
||||
>
|
||||
정지
|
||||
</button>
|
||||
@ -256,6 +360,7 @@ export function GlobalTrackReplayPanel() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Timeline slider */}
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
type="range"
|
||||
@ -272,18 +377,11 @@ export function GlobalTrackReplayPanel() {
|
||||
</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>
|
||||
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> 포인트
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showVirtualShip}
|
||||
onChange={(event) => setShowVirtualShip(event.target.checked)}
|
||||
/>{' '}
|
||||
가상선박
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> 선명
|
||||
</label>
|
||||
@ -298,9 +396,6 @@ export function GlobalTrackReplayPanel() {
|
||||
/>{' '}
|
||||
라이브 숨김
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={loop} onChange={() => toggleLoop()} /> 반복
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user