Merge pull request 'release: develop → main' (#48) from develop into main
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 38s
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 38s
Reviewed-on: #48
This commit is contained in:
커밋
913b40f2be
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;
|
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);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const target = targets.find((item) => item.mmsi === mmsi);
|
const target = targets.find((item) => item.mmsi === mmsi);
|
||||||
const isPermitted = legacyHits.has(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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user