feat(vesselSelect): 다중 선박 항적 조회 + 경고 링 개선

- 대상 선박 멀티 선택 모달 (features/vesselSelect, widgets/vesselSelect)
  · 업종/상태 필터 분리 + 그룹별 전체 on/off
  · 드래그 선택 (클릭+드래그로 범위 체크/언체크)
  · 기간 프리셋 7/14/21/28일, 최대 조회 28일 제한(초과 시 자동 조정)
  · MAX_VESSEL_SELECT=20, MAX_QUERY_DAYS=28
- trackReplay 확장: beginMultiQuery, queryMultiTrack, 다중 CSV 내보내기
- GlobalTrackReplayPanel: 기간 편집/재조회, 선박 목록 on/off 토글
- 경고 브리딩 효과: filled circle → stroked ring
  · Globe: zoom-interpolated offset 기반 반경
  · Mercator: ScatterplotLayer → IconLayer + SVG ring (깜빡임 해결)
- hideLiveShips 조회 시 기본 체크
- Topbar "다중항적" 버튼 강조 스타일
- 공지사항 id:2 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-08 12:54:20 +09:00
부모 81fb4a2bca
커밋 baf827657e
22개의 변경된 파일1477개의 추가작업 그리고 327개의 파일을 삭제

파일 보기

@ -19,3 +19,4 @@
@import "./styles/components/weather.css"; @import "./styles/components/weather.css";
@import "./styles/components/weather-overlay.css"; @import "./styles/components/weather-overlay.css";
@import "./styles/components/announcement.css"; @import "./styles/components/announcement.css";
@import "./styles/components/vessel-select-modal.css";

파일 보기

@ -0,0 +1,152 @@
/* ── Vessel select modal ─────────────────────────────────────────── */
.vessel-select-modal {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.vessel-select-modal__content {
background: rgba(15, 23, 42, 0.96);
backdrop-filter: blur(12px);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 12px;
color: #e2e8f0;
width: 95vw;
max-width: 720px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(2, 6, 23, 0.6);
overflow: hidden;
}
.vessel-select-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
font-size: 14px;
flex-shrink: 0;
}
.vessel-select-modal__close {
background: none;
border: none;
color: #94a3b8;
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
}
.vessel-select-modal__close:hover {
color: #e2e8f0;
}
.vessel-select-modal__back {
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 16px;
padding: 2px 6px;
line-height: 1;
}
.vessel-select-modal__back:hover {
color: #e2e8f0;
}
.vessel-select-modal__filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
flex-shrink: 0;
}
.vessel-select-modal__search {
padding: 8px 16px;
flex-shrink: 0;
}
.vessel-select-modal__grid {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 16px;
}
.vessel-select-modal__grid::-webkit-scrollbar {
width: 4px;
}
.vessel-select-modal__grid::-webkit-scrollbar-track {
background: transparent;
}
.vessel-select-modal__grid::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.3);
border-radius: 2px;
}
.vessel-select-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
flex-shrink: 0;
font-size: 12px;
}
.vessel-select-modal__body {
padding: 16px;
flex: 1;
overflow-y: auto;
}
.vessel-select-modal__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.vessel-select-modal__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: rgba(148, 163, 184, 0.12);
border-radius: 999px;
font-size: 11px;
color: #cbd5e1;
}
.vessel-select-modal__presets {
display: flex;
gap: 8px;
margin-top: 16px;
}
.vessel-select-modal input[type='datetime-local'] {
flex: 1;
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(30, 41, 59, 0.8);
color: #e2e8f0;
color-scheme: dark;
}

파일 보기

@ -31,14 +31,14 @@ export function buildLegacyVesselIndex(vessels: LegacyVesselInfo[]): LegacyVesse
if (score(v) > score(prev)) byName.set(k, v); if (score(v) > score(prev)) byName.set(k, v);
} }
if (typeof v.mmsi === 'number' && Number.isFinite(v.mmsi)) {
const prev = byMmsi.get(v.mmsi);
if (!prev || score(v) > score(prev)) byMmsi.set(v.mmsi, v);
}
for (const m of v.mmsiList || []) { for (const m of v.mmsiList || []) {
if (!Number.isFinite(m)) continue; if (!Number.isFinite(m)) continue;
const prev = byMmsi.get(m); if (byMmsi.has(m)) continue;
if (!prev) { byMmsi.set(m, v);
byMmsi.set(m, v);
continue;
}
if (score(v) > score(prev)) byMmsi.set(m, v);
} }
} }
@ -57,19 +57,6 @@ export function matchLegacyVessel(t: LegacyMatchable, idx: LegacyVesselIndex): L
const hit = idx.byMmsi.get(mmsi); const hit = idx.byMmsi.get(mmsi);
if (hit) return hit; if (hit) return hit;
} }
const nameKey = t.name ? normalizeShipName(t.name) : "";
if (nameKey) {
const hit = idx.byName.get(nameKey);
if (hit) return hit;
}
const csKey = t.callsign ? normalizeShipName(t.callsign) : "";
if (csKey) {
const hit = idx.byName.get(csKey);
if (hit) return hit;
}
return null; return null;
} }

파일 보기

@ -11,6 +11,7 @@ export type LegacyVesselInfo = {
shipCode: string; // PT | PT-S | GN | OT | PS | FC | ... shipCode: string; // PT | PT-S | GN | OT | PS | FC | ...
ton: number | null; ton: number | null;
callSign: string; callSign: string;
mmsi: number | null;
mmsiList: number[]; mmsiList: number[];
workSeaArea: string; workSeaArea: string;
workTerm1: string; workTerm1: string;

파일 보기

@ -28,6 +28,33 @@ export const ANNOUNCEMENTS: Announcement[] = [
}, },
], ],
}, },
{
id: 2,
title: 'Wing Fleet Dashboard 업데이트',
date: '2026-03-08',
items: [
{
icon: '🎯',
title: '대상선박 다중항적 조회',
description: '상단 "대상 선박 선택" 버튼으로 최대 20척을 한번에 선택하여 항적을 조회할 수 있습니다. 업종·상태 필터, 검색, 드래그 범위 선택을 지원합니다.',
},
{
icon: '📅',
title: '조회 기간 프리셋',
description: '7일·14일·21일·28일 프리셋 버튼으로 기간을 빠르게 설정할 수 있습니다. 최대 조회 범위는 28일이며 초과 시 자동 조정됩니다.',
},
{
icon: '🔄',
title: '항적 재조회 및 선박 목록 토글',
description: '리플레이 패널에서 기간을 수정하여 재조회하거나 CSV로 내보낼 수 있습니다. "선박 목록" 버튼으로 선택 화면을 열고 닫을 수 있습니다.',
},
{
icon: '🔔',
title: '경고 표시 개선',
description: '실시간 경고 효과가 테두리 링 형태로 변경되어 선박 아이콘과의 가독성이 향상되었습니다.',
},
],
},
]; ];
/** 현재 최신 공지 ID */ /** 현재 최신 공지 ID */

파일 보기

@ -58,6 +58,7 @@ function makeLegacy(
shipNameCn: null, shipNameCn: null,
ton: 100, ton: 100,
callSign: '', callSign: '',
mmsi: o.mmsiList[0] ?? null,
workSeaArea: '서해', workSeaArea: '서해',
workTerm1: '2025-01-01', workTerm1: '2025-01-01',
workTerm2: '2025-12-31', workTerm2: '2025-12-31',

파일 보기

@ -1,6 +1,6 @@
import { DISPLAY_TZ } from '../../../shared/lib/datetime'; import { DISPLAY_TZ } from '../../../shared/lib/datetime';
import { haversineNm } from '../../../shared/lib/geo/haversineNm'; import { haversineNm } from '../../../shared/lib/geo/haversineNm';
import type { ProcessedTrack, TrackQueryContext } from '../model/track.types'; import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types';
const BOM = '\uFEFF'; const BOM = '\uFEFF';
@ -31,14 +31,14 @@ function calcSpeedKnots(track: ProcessedTrack, index: number): number {
} }
/** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */ /** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */
export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string { export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string {
const header = ['mmsi', 'longitude', 'latitude', 'timestamp', 'timestampMs', 'speedKnots']; const header = ['mmsi', 'longitude', 'latitude', 'timestamp', 'timestampMs', 'speedKnots'];
const rows: string[] = [header.join(',')]; const rows: string[] = [header.join(',')];
const mmsi = ctx?.mmsi ?? ''; const mmsi = ctx?.mmsi ?? '';
for (const track of tracks) { for (const track of tracks) {
const trackMmsi = mmsi || track.targetId; const trackMmsi = multiCtx ? track.targetId : (mmsi || track.targetId);
for (let i = 0; i < track.geometry.length; i++) { for (let i = 0; i < track.geometry.length; i++) {
rows.push( rows.push(
[ [
@ -57,7 +57,7 @@ export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext
} }
/** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */ /** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */
export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): string { export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string {
const header = [ const header = [
'mmsi', 'mmsi',
'shipName', 'shipName',
@ -83,23 +83,29 @@ export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext
]; ];
const rows: string[] = [header.join(',')]; const rows: string[] = [header.join(',')];
// Build per-mmsi lookup for multi-vessel mode
const multiVesselMap = multiCtx
? new Map(multiCtx.vessels.map((v) => [String(v.mmsi), v]))
: null;
for (const track of tracks) { for (const track of tracks) {
const firstTs = track.timestampsMs[0] ?? 0; const firstTs = track.timestampsMs[0] ?? 0;
const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0; const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0;
const info = track.chnPrmShipInfo; const info = track.chnPrmShipInfo;
const mv = multiVesselMap?.get(track.targetId);
rows.push( rows.push(
[ [
escCsv(ctx?.mmsi ?? track.targetId), escCsv(mv?.mmsi ?? ctx?.mmsi ?? track.targetId),
escCsv(track.shipName), escCsv(track.shipName),
escCsv(ctx?.vesselType ?? ''), escCsv(mv?.vesselType ?? ctx?.vesselType ?? ''),
escCsv(ctx?.ownerCn), escCsv(mv?.ownerCn ?? ctx?.ownerCn),
escCsv(ctx?.ownerRoman), escCsv(mv?.ownerRoman ?? ctx?.ownerRoman),
escCsv(ctx?.permitNo), escCsv(mv?.permitNo ?? ctx?.permitNo),
escCsv(ctx?.pairPermitNo), escCsv(mv?.pairPermitNo ?? ctx?.pairPermitNo),
escCsv(ctx?.ton), escCsv(mv?.ton ?? ctx?.ton),
escCsv(ctx?.callSign), escCsv(mv?.callSign ?? ctx?.callSign),
escCsv(ctx?.workSeaArea), escCsv(mv?.workSeaArea ?? ctx?.workSeaArea),
escCsv(track.nationalCode), escCsv(track.nationalCode),
escCsv(track.stats.totalDistanceNm), escCsv(track.stats.totalDistanceNm),
escCsv(track.stats.avgSpeed), escCsv(track.stats.avgSpeed),
@ -130,12 +136,12 @@ function downloadCsv(csvContent: string, filename: string): void {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null): void { export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): void {
const now = new Date(); const now = new Date();
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
downloadCsv(buildDynamicCsv(tracks, ctx), `track-points-${ts}.csv`); downloadCsv(buildDynamicCsv(tracks, ctx, multiCtx), `track-points-${ts}.csv`);
setTimeout(() => { setTimeout(() => {
downloadCsv(buildStaticCsv(tracks, ctx), `track-vessel-${ts}.csv`); downloadCsv(buildStaticCsv(tracks, ctx, multiCtx), `track-vessel-${ts}.csv`);
}, 100); }, 100);
} }

파일 보기

@ -64,6 +64,25 @@ export interface TrackQueryContext {
workSeaArea?: string; workSeaArea?: string;
} }
export interface MultiTrackQueryContext {
vessels: Array<{
mmsi: number;
shipNameHint?: string;
isPermitted: boolean;
vesselType?: string;
ownerCn?: string | null;
ownerRoman?: string | null;
permitNo?: string;
pairPermitNo?: string | null;
ton?: number | null;
callSign?: string;
workSeaArea?: string;
shipCode?: string;
}>;
startTimeIso: string;
endTimeIso: string;
}
export interface ReplayStreamQueryRequest { export interface ReplayStreamQueryRequest {
startTime: string; startTime: string;
endTime: string; endTime: string;

파일 보기

@ -120,16 +120,16 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
async function fetchV2Tracks( async function fetchV2Tracks(
startTimeIso: string, startTimeIso: string,
endTimeIso: string, endTimeIso: string,
mmsi: number, mmsis: number[],
isPermitted: boolean, hasPermitted: boolean,
): Promise<ProcessedTrack[]> { ): 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 requestBody = { const requestBody = {
startTime: startTimeIso, startTime: startTimeIso,
endTime: endTimeIso, endTime: endTimeIso,
vessels: [String(mmsi)], vessels: mmsis.map(String),
includeChnPrmShip: isPermitted, includeChnPrmShip: hasPermitted,
}; };
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`; const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
@ -156,9 +156,20 @@ async function fetchV2Tracks(
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> { export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const end = new Date(); const end = new Date();
const start = new Date(end.getTime() - params.minutes * 60_000); const start = new Date(end.getTime() - params.minutes * 60_000);
return fetchV2Tracks(start.toISOString(), end.toISOString(), params.mmsi, params.isPermitted ?? false); return fetchV2Tracks(start.toISOString(), end.toISOString(), [params.mmsi], params.isPermitted ?? false);
} }
export async function queryTrackByDateRange(params: QueryTrackByDateRangeParams): Promise<ProcessedTrack[]> { export async function queryTrackByDateRange(params: QueryTrackByDateRangeParams): Promise<ProcessedTrack[]> {
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsi, params.isPermitted ?? false); return fetchV2Tracks(params.startTimeIso, params.endTimeIso, [params.mmsi], params.isPermitted ?? false);
}
export type QueryMultiTrackParams = {
mmsis: number[];
startTimeIso: string;
endTimeIso: string;
hasPermitted: boolean;
};
export async function queryMultiTrack(params: QueryMultiTrackParams): Promise<ProcessedTrack[]> {
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsis, params.hasPermitted);
} }

파일 보기

@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { getTracksTimeRange } from '../lib/adapters'; import { getTracksTimeRange } from '../lib/adapters';
import type { ProcessedTrack, TrackQueryContext } from '../model/track.types'; import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types';
import { queryTrackByDateRange } from '../services/trackQueryService'; import { queryMultiTrack, 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';
@ -16,6 +16,7 @@ interface TrackQueryState {
renderEpoch: number; renderEpoch: number;
lastQueryKey: string | null; lastQueryKey: string | null;
queryContext: TrackQueryContext | null; queryContext: TrackQueryContext | null;
multiQueryContext: MultiTrackQueryContext | null;
showPoints: boolean; showPoints: boolean;
showVirtualShip: boolean; showVirtualShip: boolean;
showLabels: boolean; showLabels: boolean;
@ -23,6 +24,7 @@ interface TrackQueryState {
hideLiveShips: boolean; hideLiveShips: boolean;
beginQuery: (queryKey: string, context?: TrackQueryContext) => void; beginQuery: (queryKey: string, context?: TrackQueryContext) => void;
beginMultiQuery: (queryKey: string, ctx: MultiTrackQueryContext) => Promise<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;
@ -53,6 +55,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
renderEpoch: 0, renderEpoch: 0,
lastQueryKey: null, lastQueryKey: null,
queryContext: null, queryContext: null,
multiQueryContext: null,
showPoints: true, showPoints: true,
showVirtualShip: true, showVirtualShip: true,
showLabels: true, showLabels: true,
@ -70,11 +73,48 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
queryState: 'loading', queryState: 'loading',
renderEpoch: state.renderEpoch + 1, renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey, lastQueryKey: queryKey,
hideLiveShips: false, hideLiveShips: true,
queryContext: context ?? state.queryContext, queryContext: context ?? state.queryContext,
multiQueryContext: null,
})); }));
}, },
beginMultiQuery: async (queryKey: string, ctx: MultiTrackQueryContext) => {
useTrackPlaybackStore.getState().reset();
set((state) => ({
tracks: [],
disabledVesselIds: new Set<string>(),
highlightedVesselId: null,
isLoading: true,
error: null,
queryState: 'loading',
renderEpoch: state.renderEpoch + 1,
lastQueryKey: queryKey,
hideLiveShips: true,
queryContext: null,
multiQueryContext: ctx,
}));
try {
const mmsis = ctx.vessels.map((v) => v.mmsi);
const hasPermitted = ctx.vessels.some((v) => v.isPermitted);
const tracks = await queryMultiTrack({
mmsis,
startTimeIso: ctx.startTimeIso,
endTimeIso: ctx.endTimeIso,
hasPermitted,
});
if (tracks.length > 0) {
get().applyTracksSuccess(tracks, queryKey);
} else {
get().applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
get().applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
},
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) {
@ -146,11 +186,21 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
renderEpoch: state.renderEpoch + 1, renderEpoch: state.renderEpoch + 1,
lastQueryKey: null, lastQueryKey: null,
queryContext: null, queryContext: null,
multiQueryContext: null,
hideLiveShips: false, hideLiveShips: false,
})); }));
}, },
requery: async (startTimeIso: string, endTimeIso: string) => { requery: async (startTimeIso: string, endTimeIso: string) => {
const multiCtx = get().multiQueryContext;
if (multiCtx) {
const queryKey = `requery-multi:${Date.now()}`;
const updatedCtx: MultiTrackQueryContext = { ...multiCtx, startTimeIso, endTimeIso };
// Preserve multiQueryContext across beginMultiQuery
await get().beginMultiQuery(queryKey, updatedCtx);
return;
}
const ctx = get().queryContext; const ctx = get().queryContext;
if (!ctx) return; if (!ctx) return;
@ -235,6 +285,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
renderEpoch: state.renderEpoch + 1, renderEpoch: state.renderEpoch + 1,
lastQueryKey: null, lastQueryKey: null,
queryContext: null, queryContext: null,
multiQueryContext: null,
showPoints: true, showPoints: true,
showVirtualShip: true, showVirtualShip: true,
showLabels: true, showLabels: true,

파일 보기

@ -0,0 +1,273 @@
import { useCallback, useState } from 'react';
import type { DerivedLegacyVessel } from '../../legacyDashboard/model/types';
import type { MultiTrackQueryContext } from '../../trackReplay/model/track.types';
import { useTrackQueryStore } from '../../trackReplay/stores/trackQueryStore';
import { MAX_VESSEL_SELECT, MAX_QUERY_DAYS } from '../model/types';
/** ms → datetime-local input value (KST = UTC+9) */
function toDateTimeLocalKST(ms: number): string {
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 DEFAULT_DAYS = 7;
export interface VesselSelectModalState {
isOpen: boolean;
open: () => void;
reopen: () => void;
close: () => void;
selectedMmsis: Set<number>;
toggleMmsi: (mmsi: number) => void;
setMmsis: (mmsis: Set<number>) => void;
selectAllFiltered: (filtered: DerivedLegacyVessel[]) => void;
clearAll: () => void;
searchQuery: string;
setSearchQuery: (q: string) => void;
shipCodeFilter: Set<string>;
toggleShipCode: (code: string) => void;
toggleAllShipCodes: (allCodes: string[]) => void;
onlySailing: boolean;
setOnlySailing: (v: boolean) => void;
stateFilter: Set<string>;
toggleStateFilter: (label: string) => void;
toggleAllStates: (allLabels: string[]) => void;
startTime: string;
endTime: string;
setStartTime: (v: string) => void;
setEndTime: (v: string) => void;
applyPresetDays: (hours: number) => void;
isQuerying: boolean;
submitQuery: (allVessels: DerivedLegacyVessel[]) => void;
position: { x: number; y: number };
setPosition: (pos: { x: number; y: number }) => void;
selectionWarning: string | null;
}
export function useVesselSelectModal(): VesselSelectModalState {
const [isOpen, setIsOpen] = useState(false);
const [selectedMmsis, setSelectedMmsis] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [shipCodeFilter, setShipCodeFilter] = useState<Set<string>>(new Set());
const [onlySailing, setOnlySailing] = useState(false);
const [stateFilter, setStateFilter] = useState<Set<string>>(new Set());
const [selectionWarning, setSelectionWarning] = useState<string | null>(null);
const [isQuerying, setIsQuerying] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [startTime, setStartTime] = useState(() => {
const n = Date.now();
return toDateTimeLocalKST(n - DEFAULT_DAYS * 86_400_000);
});
const [endTime, setEndTime] = useState(() => toDateTimeLocalKST(Date.now()));
const open = useCallback(() => {
setIsOpen(true);
setSelectedMmsis(new Set());
setSearchQuery('');
setShipCodeFilter(new Set());
setOnlySailing(false);
setStateFilter(new Set());
setSelectionWarning(null);
setIsQuerying(false);
setPosition({ x: 0, y: 0 });
const n = Date.now();
setStartTime(toDateTimeLocalKST(n - DEFAULT_DAYS * 86_400_000));
setEndTime(toDateTimeLocalKST(n));
}, []);
const reopen = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggleMmsi = useCallback((mmsi: number) => {
setSelectedMmsis((prev) => {
const next = new Set(prev);
if (next.has(mmsi)) {
next.delete(mmsi);
setSelectionWarning(null);
} else {
if (next.size >= MAX_VESSEL_SELECT) {
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다`);
return prev;
}
next.add(mmsi);
setSelectionWarning(null);
}
return next;
});
}, []);
const setMmsis = useCallback((mmsis: Set<number>) => {
if (mmsis.size > MAX_VESSEL_SELECT) {
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다`);
return;
}
setSelectedMmsis(mmsis);
setSelectionWarning(null);
}, []);
const selectAllFiltered = useCallback((filtered: DerivedLegacyVessel[]) => {
const capped = filtered.slice(0, MAX_VESSEL_SELECT);
setSelectedMmsis(new Set(capped.map((v) => v.mmsi)));
if (filtered.length > MAX_VESSEL_SELECT) {
setSelectionWarning(`최대 ${MAX_VESSEL_SELECT}척까지 선택 가능합니다 (${filtered.length}척 중 ${MAX_VESSEL_SELECT}척 선택됨)`);
} else {
setSelectionWarning(null);
}
}, []);
const clearAll = useCallback(() => {
setSelectedMmsis(new Set());
setSelectionWarning(null);
}, []);
const toggleShipCode = useCallback((code: string) => {
setShipCodeFilter((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
}, []);
const toggleAllShipCodes = useCallback((allCodes: string[]) => {
setShipCodeFilter((prev) =>
prev.size === allCodes.length ? new Set() : new Set(allCodes),
);
}, []);
const toggleStateFilter = useCallback((label: string) => {
setStateFilter((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const toggleAllStates = useCallback((allLabels: string[]) => {
setStateFilter((prev) =>
prev.size === allLabels.length ? new Set() : new Set(allLabels),
);
}, []);
const applyPresetDays = useCallback(
(days: number) => {
const now = Date.now();
const spanMs = days * 86_400_000;
// datetime-local (KST) → ms (UTC)
const parseKST = (v: string) => new Date(`${v}:00+09:00`).getTime();
const curStart = parseKST(startTime);
const curEnd = parseKST(endTime);
const curGap = curEnd - curStart;
if (curGap > spanMs) {
// 현재 간격이 프리셋보다 넓으면 → 시작을 종료 기준으로 조정
const cappedEnd = Math.min(curEnd, now);
setEndTime(toDateTimeLocalKST(cappedEnd));
setStartTime(toDateTimeLocalKST(cappedEnd - spanMs));
} else {
// 시작 기준으로 종료 확장, 종료 max = 현재
const newEnd = Math.min(curStart + spanMs, now);
setEndTime(toDateTimeLocalKST(newEnd));
// 종료가 clamp 되었으면 시작도 조정
if (newEnd - curStart < spanMs) {
setStartTime(toDateTimeLocalKST(newEnd - spanMs));
}
}
},
[startTime, endTime],
);
const submitQuery = useCallback(
(allVessels: DerivedLegacyVessel[]) => {
const selected = allVessels.filter((v) => selectedMmsis.has(v.mmsi));
if (selected.length === 0) return;
const maxMs = MAX_QUERY_DAYS * 86_400_000;
const sMs = new Date(fromDateTimeLocalKST(startTime)).getTime();
const eMs = new Date(fromDateTimeLocalKST(endTime)).getTime();
if (eMs - sMs > maxMs) {
const clampedEnd = toDateTimeLocalKST(sMs + maxMs);
setEndTime(clampedEnd);
setSelectionWarning(`최대 ${MAX_QUERY_DAYS}일 초과 — 종료일을 자동 조정했습니다`);
return;
}
const vessels = selected.map((v) => ({
mmsi: v.mmsi,
shipNameHint: v.name,
isPermitted: true,
vesselType: v.shipCode,
ownerCn: v.ownerCn,
ownerRoman: v.ownerRoman,
permitNo: v.permitNo,
pairPermitNo: v.pairPermitNo,
ton: v.legacy.ton,
callSign: v.callsign ?? undefined,
workSeaArea: v.workSeaArea ?? undefined,
shipCode: v.shipCode,
}));
const ctx: MultiTrackQueryContext = {
vessels,
startTimeIso: fromDateTimeLocalKST(startTime),
endTimeIso: fromDateTimeLocalKST(endTime),
};
const queryKey = `multi:${selected.length}:${Date.now()}`;
useTrackQueryStore.getState().beginMultiQuery(queryKey, ctx);
setIsQuerying(true);
setIsOpen(false);
},
[selectedMmsis, startTime, endTime],
);
return {
isOpen,
open,
reopen,
close,
selectedMmsis,
toggleMmsi,
setMmsis,
selectAllFiltered,
clearAll,
searchQuery,
setSearchQuery,
shipCodeFilter,
toggleShipCode,
toggleAllShipCodes,
onlySailing,
setOnlySailing,
stateFilter,
toggleStateFilter,
toggleAllStates,
startTime,
endTime,
setStartTime,
setEndTime,
applyPresetDays,
isQuerying,
submitQuery,
position,
setPosition,
selectionWarning,
};
}

파일 보기

@ -0,0 +1,3 @@
export type { VesselDescriptor } from './model/types';
export { MAX_VESSEL_SELECT } from './model/types';
export { useVesselSelectModal } from './hooks/useVesselSelectModal';

파일 보기

@ -0,0 +1,19 @@
export interface VesselDescriptor {
mmsi: number;
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;
shipCode?: string;
}
export const MAX_VESSEL_SELECT = 20;
export const MAX_QUERY_DAYS = 28;

파일 보기

@ -31,6 +31,8 @@ import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlay
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel"; import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement"; import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
import { useVesselSelectModal } from "../../features/vesselSelect";
import { VesselSelectModal } from "../../widgets/vesselSelect/VesselSelectModal";
import { import {
buildLegacyHitMap, buildLegacyHitMap,
computeCountsByType, computeCountsByType,
@ -67,6 +69,9 @@ export function DashboardPage() {
// ── Announcement popup ── // ── Announcement popup ──
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid); const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
// ── Vessel select modal (multi-track) ──
const vesselSelectModal = useVesselSelectModal();
// ── Data fetching ── // ── Data fetching ──
const { data: zones, error: zonesError } = useZones(); const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels(); const { data: legacyData, error: legacyError } = useLegacyVessels();
@ -351,6 +356,7 @@ export function DashboardPage() {
onToggleTheme={toggleTheme} onToggleTheme={toggleTheme}
isSidebarOpen={isSidebarOpen} isSidebarOpen={isSidebarOpen}
onMenuToggle={() => setIsSidebarOpen((v) => !v)} onMenuToggle={() => setIsSidebarOpen((v) => !v)}
onOpenMultiTrack={vesselSelectModal.open}
/> />
<DashboardSidebar <DashboardSidebar
@ -442,7 +448,13 @@ export function DashboardPage() {
freeCamera={state.freeCamera} freeCamera={state.freeCamera}
oceanMapSettings={state.oceanMapSettings} oceanMapSettings={state.oceanMapSettings}
/> />
<GlobalTrackReplayPanel /> <GlobalTrackReplayPanel
isVesselListOpen={vesselSelectModal.isOpen}
onToggleVesselList={() => {
if (vesselSelectModal.isOpen) vesselSelectModal.close();
else vesselSelectModal.reopen();
}}
/>
<WeatherPanel <WeatherPanel
snapshot={weather.snapshot} snapshot={weather.snapshot}
isLoading={weather.isLoading} isLoading={weather.isLoading}
@ -465,6 +477,9 @@ export function DashboardPage() {
{hasUnread && ( {hasUnread && (
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} /> <AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
)} )}
{vesselSelectModal.isOpen && (
<VesselSelectModal modal={vesselSelectModal} vessels={legacyVesselsAll} />
)}
{imageModal && ( {imageModal && (
<ShipImageModal <ShipImageModal
images={imageModal.images} images={imageModal.images}

파일 보기

@ -3,7 +3,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox';
import { type PickingInfo } from '@deck.gl/core'; import { type PickingInfo } from '@deck.gl/core';
import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { AisTarget } from '../../../entities/aisTarget/model/types';
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
import { ScatterplotLayer } from '@deck.gl/layers'; import { IconLayer, ScatterplotLayer } from '@deck.gl/layers';
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types'; import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
@ -20,6 +20,8 @@ import {
import { sanitizeDeckLayerList } from '../lib/mapCore'; import { sanitizeDeckLayerList } from '../lib/mapCore';
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories'; import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
import { import {
FLAT_LEGACY_HALO_RADIUS,
FLAT_LEGACY_HALO_RADIUS_SELECTED,
HALO_BREATHE_PERIOD_MS, HALO_BREATHE_PERIOD_MS,
HALO_BREATHE_SELECTED_R_MIN, HALO_BREATHE_SELECTED_R_MIN,
HALO_BREATHE_SELECTED_R_MAX, HALO_BREATHE_SELECTED_R_MAX,
@ -31,6 +33,12 @@ import {
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts. // Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
const ENABLE_GLOBE_DECK_OVERLAYS = false; const ENABLE_GLOBE_DECK_OVERLAYS = false;
/** 64×64 white ring SVG for alarm pulse IconLayer */
const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
)}`;
const ALARM_RING_ICON_MAPPING = { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } } as const;
export function useDeckLayers( export function useDeckLayers(
mapRef: MutableRefObject<import('maplibre-gl').Map | null>, mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
@ -308,30 +316,35 @@ export function useDeckLayers(
const now = Date.now(); const now = Date.now();
let updated = mercatorLayersRef.current; let updated = mercatorLayersRef.current;
// 1. 알람 맥동 (기존) // 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션
if (hasAlarms) { if (hasAlarms) {
const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2; const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2;
const normalR = 8 + tA * 6;
const hoverR = 12 + tA * 6;
const pulseLyr = new ScatterplotLayer<AisTarget>({ const pulseLyr = new IconLayer<AisTarget>({
id: 'alarm-pulse', id: 'alarm-pulse',
data: alarmTargets, data: alarmTargets,
pickable: false, pickable: false,
billboard: false, billboard: true,
filled: true, iconAtlas: ALARM_RING_ICON_URL,
stroked: false, iconMapping: ALARM_RING_ICON_MAPPING,
radiusUnits: 'pixels', getIcon: () => 'ring',
getRadius: (d) => { sizeUnits: 'pixels',
sizeScale: 0.9 + tA * 0.2,
opacity: 0.2 + tA * 0.7,
getSize: (d) => {
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi); const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
return isHover ? hoverR : normalR; return isHover
? (FLAT_LEGACY_HALO_RADIUS_SELECTED + 8) * 2
: (FLAT_LEGACY_HALO_RADIUS + 8) * 2;
}, },
getFillColor: (d) => { getColor: (d) => {
const kind = alarmMmsiMap!.get(d.mmsi); const kind = alarmMmsiMap!.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number]; return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
}, },
getPosition: (d) => [d.lon, d.lat] as [number, number], getPosition: (d) => [d.lon, d.lat] as [number, number],
updateTriggers: { getRadius: [normalR, hoverR] }, updateTriggers: {
getSize: [selectedMmsi],
},
}); });
updated = updated.map((l) => updated = updated.map((l) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

파일 보기

@ -28,12 +28,41 @@ import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers'; import { guardedSetVisibility } from '../lib/layerHelpers';
// ── Alarm pulse animation constants ── // ── Alarm pulse animation constants ──
const ALARM_PULSE_R_MIN = 8; // Offset from outline radius so pulse ring never overlaps the outline stroke
const ALARM_PULSE_R_MAX = 14; const ALARM_PULSE_OFFSET_MIN = 5; // px offset from base circle at rest
const ALARM_PULSE_R_HOVER_MIN = 12; const ALARM_PULSE_OFFSET_MAX = 11; // px offset at peak
const ALARM_PULSE_R_HOVER_MAX = 18; const ALARM_PULSE_HOVER_OFFSET_MIN = 7;
const ALARM_PULSE_HOVER_OFFSET_MAX = 14;
const ALARM_PULSE_PERIOD_MS = 1500; const ALARM_PULSE_PERIOD_MS = 1500;
// Base circle radii per zoom (from mlExpressions BASE_VALUES)
const BASE_R_BY_ZOOM = [
[3, 4], [7, 6], [10, 8], [14, 12], [18, 32],
] as const;
/** Build zoom-interpolated radius = base + offset for alarm pulse */
function buildAlarmPulseRadiusExpr(offset: number) {
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
for (const [z, base] of BASE_R_BY_ZOOM) {
stops.push(z, base + offset);
}
return stops as never;
}
/** Build zoom-interpolated radius with hover/normal case for alarm pulse */
function buildAlarmPulseRadiusCaseExpr(normalOffset: number, hoverOffset: number) {
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
for (const [z, base] of BASE_R_BY_ZOOM) {
stops.push(z, [
'case',
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
base + hoverOffset,
base + normalOffset,
]);
}
return stops as never;
}
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */ /** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
export function useGlobeShipLayers( export function useGlobeShipLayers(
mapRef: MutableRefObject<maplibregl.Map | null>, mapRef: MutableRefObject<maplibregl.Map | null>,
@ -372,10 +401,12 @@ export function useGlobeShipLayers(
filter: ['==', ['get', 'alarmed'], 1] as never, filter: ['==', ['get', 'alarmed'], 1] as never,
layout: { visibility }, layout: { visibility },
paint: { paint: {
'circle-radius': ALARM_PULSE_R_MIN, 'circle-radius': buildAlarmPulseRadiusExpr(ALARM_PULSE_OFFSET_MIN),
'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never, 'circle-color': 'transparent',
'circle-opacity': 0.35, 'circle-opacity': 0,
'circle-stroke-width': 0, 'circle-stroke-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
'circle-stroke-opacity': 0.7,
'circle-stroke-width': 1.5,
}, },
} as unknown as LayerSpecification, } as unknown as LayerSpecification,
before, before,
@ -701,16 +732,16 @@ export function useGlobeShipLayers(
return; return;
} }
const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2; const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN); const normalOff = ALARM_PULSE_OFFSET_MIN + t * (ALARM_PULSE_OFFSET_MAX - ALARM_PULSE_OFFSET_MIN);
const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN); const hoverOff = ALARM_PULSE_HOVER_OFFSET_MIN + t * (ALARM_PULSE_HOVER_OFFSET_MAX - ALARM_PULSE_HOVER_OFFSET_MIN);
try { try {
if (map.getLayer('ships-globe-alarm-pulse')) { if (map.getLayer('ships-globe-alarm-pulse')) {
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [ map.setPaintProperty(
'case', 'ships-globe-alarm-pulse',
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]], 'circle-radius',
hoverR, buildAlarmPulseRadiusCaseExpr(normalOff, hoverOff),
normalR, );
] as never); map.setPaintProperty('ships-globe-alarm-pulse', 'circle-stroke-opacity', 0.4 + t * 0.5);
} }
} catch { } catch {
// ignore // ignore

파일 보기

@ -41,6 +41,11 @@ import {
SPEED_THRESHOLD_KN, SPEED_THRESHOLD_KN,
} from '../../../shared/lib/map/shipKind'; } from '../../../shared/lib/map/shipKind';
/** 64×64 white ring SVG — mask:true로 getColor 틴트 적용 */
const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
)}`;
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */ /* ── 공통 콜백 인터페이스 ─────────────────────────────── */
interface DeckHoverCallbacks { interface DeckHoverCallbacks {
@ -455,32 +460,33 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
} }
} }
/* ─ alarm pulse + badge ─ */ /* ─ alarm pulse (IconLayer + SVG ring) + badge ─ */
const alarmTargets = ctx.alarmTargets ?? []; const alarmTargets = ctx.alarmTargets ?? [];
const alarmMap = ctx.alarmMmsiMap; const alarmMap = ctx.alarmMmsiMap;
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) { if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
const pulseR = ctx.alarmPulseRadius ?? 8; const pulseSize = ctx.alarmPulseRadius ?? 40;
const pulseHR = ctx.alarmPulseHoverRadius ?? 12; const pulseHoverSize = ctx.alarmPulseHoverRadius ?? 48;
layers.push( layers.push(
new ScatterplotLayer<AisTarget>({ new IconLayer<AisTarget>({
id: 'alarm-pulse', id: 'alarm-pulse',
data: alarmTargets, data: alarmTargets,
pickable: false, pickable: false,
billboard: false, billboard: true,
parameters: overlayParams, parameters: overlayParams,
filled: true, iconAtlas: ALARM_RING_ICON_URL,
stroked: false, iconMapping: { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } },
radiusUnits: 'pixels', getIcon: () => 'ring',
getRadius: (d) => { sizeUnits: 'pixels',
getSize: (d) => {
const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi); const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi);
return isHover ? pulseHR : pulseR; return isHover ? pulseHoverSize : pulseSize;
}, },
getFillColor: (d) => { getColor: (d) => {
const kind = alarmMap.get(d.mmsi); const kind = alarmMap.get(d.mmsi);
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number]; return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
}, },
getPosition: (d) => [d.lon, d.lat] as [number, number], getPosition: (d) => [d.lon, d.lat] as [number, number],
updateTriggers: { getRadius: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] }, updateTriggers: { getSize: [pulseSize, pulseHoverSize, ctx.selectedMmsi, ctx.shipHighlightSet] },
}), }),
); );
layers.push( layers.push(

파일 보기

@ -15,6 +15,7 @@ interface Props {
onToggleTheme?: () => void; onToggleTheme?: () => void;
isSidebarOpen?: boolean; isSidebarOpen?: boolean;
onMenuToggle?: () => void; onMenuToggle?: () => void;
onOpenMultiTrack?: () => void;
} }
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) { function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
@ -39,7 +40,7 @@ function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "
); );
} }
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) { export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle, onOpenMultiTrack }: Props) {
const [isStatsOpen, setIsStatsOpen] = useState(false); const [isStatsOpen, setIsStatsOpen] = useState(false);
return ( return (
@ -83,6 +84,15 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, admi
{/* 데스크톱: 인라인 통계 */} {/* 데스크톱: 인라인 통계 */}
<div className="ml-auto hidden items-center gap-3.5 md:flex"> <div className="ml-auto hidden items-center gap-3.5 md:flex">
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} /> <StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
{onOpenMultiTrack && (
<button
className="cursor-pointer whitespace-nowrap rounded-md border border-blue-500/50 bg-blue-600/20 px-2.5 py-1 text-[11px] font-semibold text-blue-300 transition-all duration-150 hover:border-blue-400 hover:bg-blue-600/30 hover:text-blue-200"
onClick={onOpenMultiTrack}
title="다중 선박 항적 조회"
>
</button>
)}
</div> </div>
{/* 항상 표시: 시계 + 테마 + 사용자 */} {/* 항상 표시: 시계 + 테마 + 사용자 */}

파일 보기

@ -1,7 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; import { useCallback, useMemo, useState } 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'; import { exportTrackCsv } from '../../features/trackReplay/lib/csvExport';
import { SHIP_KIND_COLORS } from '../../shared/lib/map/shipKind';
import type { ProcessedTrack } from '../../features/trackReplay/model/track.types';
import { MAX_QUERY_DAYS } from '../../features/vesselSelect/model/types';
function formatDateTime(ms: number): string { function formatDateTime(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '--'; if (!Number.isFinite(ms) || ms <= 0) return '--';
@ -12,14 +15,12 @@ function formatDateTime(ms: number): string {
)}:${pad(date.getSeconds())}`; )}:${pad(date.getSeconds())}`;
} }
/** ms → datetime-local input value (KST = UTC+9) */
function toDateTimeLocalKST(ms: number): string { function toDateTimeLocalKST(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return ''; if (!Number.isFinite(ms) || ms <= 0) return '';
const kstDate = new Date(ms + 9 * 3600_000); const kstDate = new Date(ms + 9 * 3600_000);
return kstDate.toISOString().slice(0, 16); return kstDate.toISOString().slice(0, 16);
} }
/** datetime-local value (KST) → ISO string */
function fromDateTimeLocalKST(value: string): string { function fromDateTimeLocalKST(value: string): string {
return `${value}:00+09:00`; return `${value}:00+09:00`;
} }
@ -44,46 +45,18 @@ const btnBase: React.CSSProperties = {
cursor: 'pointer', cursor: 'pointer',
}; };
export function GlobalTrackReplayPanel() { interface GlobalTrackReplayPanelProps {
const PANEL_WIDTH = 420; isVesselListOpen?: boolean;
const PANEL_MARGIN = 12; onToggleVesselList?: () => void;
const PANEL_DEFAULT_TOP = 16; }
const PANEL_RIGHT_RESERVED = 520;
const panelRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(
null,
);
const [isDragging, setIsDragging] = useState(false);
const clampPosition = useCallback(
(x: number, y: number) => {
if (typeof window === 'undefined') return { x, y };
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const panelHeight = panelRef.current?.offsetHeight ?? 360;
return {
x: Math.min(Math.max(PANEL_MARGIN, x), Math.max(PANEL_MARGIN, viewportWidth - PANEL_WIDTH - PANEL_MARGIN)),
y: Math.min(Math.max(PANEL_MARGIN, y), Math.max(PANEL_MARGIN, viewportHeight - panelHeight - PANEL_MARGIN)),
};
},
[PANEL_MARGIN, PANEL_WIDTH],
);
const [position, setPosition] = useState(() => {
if (typeof window === 'undefined') {
return { x: PANEL_MARGIN, y: PANEL_DEFAULT_TOP };
}
return {
x: Math.max(PANEL_MARGIN, window.innerWidth - PANEL_WIDTH - PANEL_RIGHT_RESERVED),
y: PANEL_DEFAULT_TOP,
};
});
export function GlobalTrackReplayPanel({ isVesselListOpen, onToggleVesselList }: GlobalTrackReplayPanelProps) {
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 queryContext = useTrackQueryStore((state) => state.queryContext);
const multiQueryContext = useTrackQueryStore((state) => state.multiQueryContext);
const disabledVesselIds = useTrackQueryStore((state) => state.disabledVesselIds);
const showPoints = useTrackQueryStore((state) => state.showPoints); const showPoints = useTrackQueryStore((state) => state.showPoints);
const showLabels = useTrackQueryStore((state) => state.showLabels); const showLabels = useTrackQueryStore((state) => state.showLabels);
const showTrail = useTrackQueryStore((state) => state.showTrail); const showTrail = useTrackQueryStore((state) => state.showTrail);
@ -94,6 +67,7 @@ export function GlobalTrackReplayPanel() {
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 requery = useTrackQueryStore((state) => state.requery);
const toggleVesselEnabled = useTrackQueryStore((state) => state.toggleVesselEnabled);
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying); const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
const currentTime = useTrackPlaybackStore((state) => state.currentTime); const currentTime = useTrackPlaybackStore((state) => state.currentTime);
@ -120,92 +94,48 @@ export function GlobalTrackReplayPanel() {
const setEditStartTime = (v: string) => setEditState((prev) => ({ ...prev, start: v })); const setEditStartTime = (v: string) => setEditState((prev) => ({ ...prev, start: v }));
const setEditEndTime = (v: string) => setEditState((prev) => ({ ...prev, end: v })); const setEditEndTime = (v: string) => setEditState((prev) => ({ ...prev, end: v }));
const [requeryWarning, setRequeryWarning] = useState<string | null>(null);
const handleRequery = useCallback(() => { const handleRequery = useCallback(() => {
if (!editStartTime || !editEndTime) return; if (!editStartTime || !editEndTime) return;
const sMs = new Date(fromDateTimeLocalKST(editStartTime)).getTime();
const eMs = new Date(fromDateTimeLocalKST(editEndTime)).getTime();
const maxMs = MAX_QUERY_DAYS * 86_400_000;
if (eMs - sMs > maxMs) {
const clamped = toDateTimeLocalKST(sMs + maxMs);
setEditEndTime(clamped);
setRequeryWarning(`최대 ${MAX_QUERY_DAYS}일 초과 — 종료일을 자동 조정했습니다`);
return;
}
setRequeryWarning(null);
requery(fromDateTimeLocalKST(editStartTime), fromDateTimeLocalKST(editEndTime)); requery(fromDateTimeLocalKST(editStartTime), fromDateTimeLocalKST(editEndTime));
}, [editStartTime, editEndTime, requery]); }, [editStartTime, editEndTime, requery]);
const handleExportCsv = useCallback(() => { const handleExportCsv = useCallback(() => {
if (tracks.length === 0) return; if (tracks.length === 0) return;
exportTrackCsv(tracks, queryContext); exportTrackCsv(tracks, queryContext, multiQueryContext);
}, [tracks, queryContext]); }, [tracks, queryContext, multiQueryContext]);
const progress = useMemo(() => { const progress = useMemo(() => {
if (endTime <= startTime) return 0; if (endTime <= startTime) return 0;
return ((currentTime - startTime) / (endTime - startTime)) * 100; return ((currentTime - startTime) / (endTime - startTime)) * 100;
}, [startTime, endTime, currentTime]); }, [startTime, endTime, currentTime]);
const isVisible = isLoading || tracks.length > 0 || !!error; const isVisible = isLoading || tracks.length > 0 || !!error;
const isMultiMode = multiQueryContext != null;
useEffect(() => { const vesselCount = tracks.length;
if (!isVisible) return; const [isVesselListExpanded, setIsVesselListExpanded] = useState(false);
if (typeof window === 'undefined') return; const hasRequeryContext = isMultiMode || !!queryContext;
const onResize = () => {
setPosition((prev) => clampPosition(prev.x, prev.y));
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [clampPosition, isVisible]);
useEffect(() => {
if (!isVisible) return;
const onPointerMove = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setPosition(() => {
const nextX = drag.originX + (event.clientX - drag.startX);
const nextY = drag.originY + (event.clientY - drag.startY);
return clampPosition(nextX, nextY);
});
};
const stopDrag = (event: PointerEvent) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
setIsDragging(false);
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', stopDrag);
window.addEventListener('pointercancel', stopDrag);
return () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', stopDrag);
window.removeEventListener('pointercancel', stopDrag);
};
}, [clampPosition, isVisible]);
const handleHeaderPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: position.x,
originY: position.y,
};
setIsDragging(true);
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
},
[position.x, position.y],
);
if (!isVisible) return null; if (!isVisible) return null;
return ( return (
<div <div
ref={panelRef}
style={{ style={{
position: 'absolute', position: 'absolute',
left: position.x, bottom: 12,
top: position.y, left: '50%',
width: PANEL_WIDTH, transform: 'translateX(-50%)',
width: 'min(95vw, 700px)',
background: 'rgba(15,23,42,0.94)', background: 'rgba(15,23,42,0.94)',
border: '1px solid rgba(148,163,184,0.35)', border: '1px solid rgba(148,163,184,0.35)',
borderRadius: 12, borderRadius: 12,
@ -217,103 +147,79 @@ export function GlobalTrackReplayPanel() {
}} }}
> >
{/* Header */} {/* Header */}
<div <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
onPointerDown={handleHeaderPointerDown} <strong style={{ fontSize: 13 }}>
style={{ Track Replay{vesselCount > 0 ? ` (${vesselCount}척)` : ''}
display: 'flex', </strong>
alignItems: 'center', <div style={{ display: 'flex', gap: 6 }}>
justifyContent: 'space-between', {onToggleVesselList && (
marginBottom: 8, <button
cursor: isDragging ? 'grabbing' : 'grab', type="button"
userSelect: 'none', onClick={onToggleVesselList}
touchAction: 'none', style={{
}} fontSize: 11,
> padding: '4px 8px',
<strong style={{ fontSize: 13 }}>Track Replay</strong> borderRadius: 6,
<button border: `1px solid ${isVesselListOpen ? 'rgba(96,165,250,0.7)' : 'rgba(96,165,250,0.35)'}`,
type="button" background: isVesselListOpen ? 'rgba(37,99,235,0.35)' : 'rgba(37,99,235,0.12)',
onClick={() => closeTrackQuery()} color: isVesselListOpen ? '#bfdbfe' : '#93c5fd',
onPointerDown={(event) => event.stopPropagation()} cursor: 'pointer',
style={{ }}
fontSize: 11, >
padding: '4px 8px',
borderRadius: 6, </button>
border: '1px solid rgba(148,163,184,0.5)', )}
background: 'rgba(30,41,59,0.7)', <button
color: '#e2e8f0', type="button"
cursor: 'pointer', onClick={() => closeTrackQuery()}
}} style={{
> fontSize: 11,
padding: '4px 8px',
</button> borderRadius: 6,
border: '1px solid rgba(148,163,184,0.5)',
background: 'rgba(30,41,59,0.7)',
color: '#e2e8f0',
cursor: 'pointer',
}}
>
</button>
</div>
</div> </div>
{error ? ( {error ? <div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div> : null}
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div> {requeryWarning ? <div style={{ marginBottom: 8, color: '#fbbf24', fontSize: 12 }}>{requeryWarning}</div> : null}
) : null}
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null} {isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}> ...</div> : null}
{/* Vessel count */} {/* Vessel list — dynamic layout */}
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}> {isMultiMode && vesselCount > 0 ? (
{tracks.length} <VesselListSection
</div> tracks={tracks}
disabledVesselIds={disabledVesselIds}
toggleVesselEnabled={toggleVesselEnabled}
vesselCount={vesselCount}
isExpanded={isVesselListExpanded}
onToggleExpand={() => setIsVesselListExpanded((v) => !v)}
/>
) : vesselCount > 0 ? (
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}> {vesselCount}</div>
) : null}
{/* Date range editing */} {/* Date range editing */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label> <label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input <input type="datetime-local" title="시작 시각" value={editStartTime} onChange={(e) => setEditStartTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
type="datetime-local"
value={editStartTime}
onChange={(e) => setEditStartTime(e.target.value)}
disabled={isLoading || !queryContext}
style={inputStyle}
/>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label> <label style={{ fontSize: 11, minWidth: 28, color: '#94a3b8' }}></label>
<input <input type="datetime-local" title="종료 시각" value={editEndTime} onChange={(e) => setEditEndTime(e.target.value)} disabled={isLoading || !hasRequeryContext} style={inputStyle} />
type="datetime-local"
value={editEndTime}
onChange={(e) => setEditEndTime(e.target.value)}
disabled={isLoading || !queryContext}
style={inputStyle}
/>
</div> </div>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button <button type="button" onClick={handleRequery} disabled={isLoading || !hasRequeryContext || !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' }}>
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>
<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' }}>
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 CSV
</button> </button>
</div> </div>
@ -321,40 +227,17 @@ export function GlobalTrackReplayPanel() {
{/* Playback controls */} {/* Playback controls */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<button <button type="button" onClick={() => (isPlaying ? pause() : play())} disabled={tracks.length === 0} style={btnBase}>
type="button"
onClick={() => (isPlaying ? pause() : play())}
disabled={tracks.length === 0}
style={btnBase}
>
{isPlaying ? '일시정지' : '재생'} {isPlaying ? '일시정지' : '재생'}
</button> </button>
<button <button type="button" onClick={() => stop()} disabled={tracks.length === 0} style={btnBase}>
type="button"
onClick={() => stop()}
disabled={tracks.length === 0}
style={btnBase}
>
</button> </button>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}> <label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
<select <select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value))} style={{ background: 'rgba(30,41,59,0.85)', border: '1px solid rgba(148,163,184,0.45)', borderRadius: 6, color: '#e2e8f0', fontSize: 12, padding: '4px 6px' }}>
value={playbackSpeed}
onChange={(event) => setPlaybackSpeed(Number(event.target.value))}
style={{
background: 'rgba(30,41,59,0.85)',
border: '1px solid rgba(148,163,184,0.45)',
borderRadius: 6,
color: '#e2e8f0',
fontSize: 12,
padding: '4px 6px',
}}
>
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => ( {TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
<option key={speed} value={speed}> <option key={speed} value={speed}>{speed}x</option>
{speed}x
</option>
))} ))}
</select> </select>
</label> </label>
@ -362,15 +245,7 @@ export function GlobalTrackReplayPanel() {
{/* Timeline slider */} {/* Timeline slider */}
<div style={{ marginBottom: 10 }}> <div style={{ marginBottom: 10 }}>
<input <input type="range" title="타임라인" min={startTime} max={endTime || startTime + 1} value={currentTime} onChange={(event) => setCurrentTime(Number(event.target.value))} style={{ width: '100%' }} disabled={tracks.length === 0 || endTime <= startTime} />
type="range"
min={startTime}
max={endTime || startTime + 1}
value={currentTime}
onChange={(event) => setCurrentTime(Number(event.target.value))}
style={{ width: '100%' }}
disabled={tracks.length === 0 || endTime <= startTime}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
<span>{formatDateTime(currentTime)}</span> <span>{formatDateTime(currentTime)}</span>
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span> <span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
@ -379,24 +254,57 @@ export function GlobalTrackReplayPanel() {
{/* Display toggles */} {/* Display toggles */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 6, fontSize: 12 }}> <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)} /> </label>
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> <label><input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} /> </label>
</label> <label><input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} /> </label>
<label> <label><input type="checkbox" checked={hideLiveShips} onChange={(event) => setHideLiveShips(event.target.checked)} /> </label>
<input type="checkbox" checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)} />
</label>
<label>
<input type="checkbox" checked={showTrail} onChange={(event) => setShowTrail(event.target.checked)} />
</label>
<label>
<input
type="checkbox"
checked={hideLiveShips}
onChange={(event) => setHideLiveShips(event.target.checked)}
/>{' '}
</label>
</div> </div>
</div> </div>
); );
} }
/* ── Vessel list sub-component ── */
interface VesselListSectionProps {
tracks: ProcessedTrack[];
disabledVesselIds: Set<string>;
toggleVesselEnabled: (vesselId: string) => void;
vesselCount: number;
isExpanded: boolean;
onToggleExpand: () => void;
}
function VesselListSection({ tracks, disabledVesselIds, toggleVesselEnabled, vesselCount, isExpanded, onToggleExpand }: VesselListSectionProps) {
const showExpandToggle = vesselCount >= 5;
const alwaysShow = vesselCount <= 4;
const isListVisible = alwaysShow || isExpanded;
return (
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ fontSize: 11, color: '#93c5fd' }}> {vesselCount}</span>
{showExpandToggle && (
<button type="button" onClick={onToggleExpand} style={{ fontSize: 10, color: '#94a3b8', background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px' }}>
{isExpanded ? '▴ 접기' : '▾ 펼치기'}
</button>
)}
</div>
{isListVisible && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: showExpandToggle ? 120 : undefined, overflowY: showExpandToggle ? 'auto' : undefined }}>
{tracks.map((track) => {
const isEnabled = !disabledVesselIds.has(track.vesselId);
const kindColor = SHIP_KIND_COLORS[track.shipKindCode] || '#607D8B';
return (
<label key={track.vesselId} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 6px', borderRadius: 999, background: isEnabled ? 'rgba(148,163,184,0.12)' : 'rgba(148,163,184,0.04)', fontSize: 10, color: isEnabled ? '#cbd5e1' : '#64748B', cursor: 'pointer', opacity: isEnabled ? 1 : 0.5 }}>
<input type="checkbox" checked={isEnabled} onChange={() => toggleVesselEnabled(track.vesselId)} style={{ width: 10, height: 10, accentColor: kindColor }} />
<span style={{ width: 6, height: 6, borderRadius: '50%', background: kindColor, flexShrink: 0 }} />
{track.shipName}
<span style={{ color: '#64748B' }}>({track.targetId.slice(-5)})</span>
</label>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,203 @@
import { useState, useEffect, useCallback, type CSSProperties } from 'react';
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
import { VESSEL_TYPES } from '../../entities/vessel/model/meta';
interface VesselSelectGridProps {
vessels: DerivedLegacyVessel[];
selectedMmsis: Set<number>;
toggleMmsi: (mmsi: number) => void;
setMmsis: (mmsis: Set<number>) => void;
}
interface DragState {
startIdx: number;
endIdx: number;
direction: 'check' | 'uncheck';
}
const STYLE_TABLE: CSSProperties = {
width: '100%',
borderCollapse: 'collapse',
fontSize: 11,
};
const STYLE_TH: CSSProperties = {
position: 'sticky',
top: 0,
background: 'rgba(15,23,42,0.98)',
color: '#94a3b8',
textAlign: 'left',
padding: '6px 8px',
borderBottom: '1px solid rgba(148,163,184,0.2)',
fontWeight: 500,
};
const STYLE_TH_CHECKBOX: CSSProperties = {
...STYLE_TH,
width: 28,
};
function getTdStyle(isSelected: boolean): CSSProperties {
return {
padding: '5px 8px',
borderBottom: '1px solid rgba(148,163,184,0.08)',
cursor: 'pointer',
background: isSelected ? 'rgba(59,130,246,0.12)' : undefined,
};
}
function getStateBadgeStyle(isFishing: boolean, isTransit: boolean): CSSProperties {
const color = isFishing ? '#22C55E' : isTransit ? '#3B82F6' : '#64748B';
return {
background: `${color}22`,
color,
borderRadius: 3,
padding: '1px 4px',
fontSize: 10,
};
}
function getDotStyle(color: string): CSSProperties {
return {
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: color,
marginRight: 4,
verticalAlign: 'middle',
};
}
function isInDragRange(idx: number, drag: DragState): boolean {
const min = Math.min(drag.startIdx, drag.endIdx);
const max = Math.max(drag.startIdx, drag.endIdx);
return idx >= min && idx <= max;
}
function getDragHighlight(direction: 'check' | 'uncheck'): string {
return direction === 'check' ? 'rgba(59,130,246,0.18)' : 'rgba(148,163,184,0.12)';
}
export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis }: VesselSelectGridProps) {
const [dragState, setDragState] = useState<DragState | null>(null);
const handleMouseDown = useCallback(
(idx: number, e: React.MouseEvent) => {
// 체크박스 직접 클릭은 무시 (기존 onChange 처리)
if ((e.target as HTMLElement).tagName === 'INPUT') return;
e.preventDefault();
const isSelected = selectedMmsis.has(vessels[idx].mmsi);
setDragState({ startIdx: idx, endIdx: idx, direction: isSelected ? 'uncheck' : 'check' });
},
[vessels, selectedMmsis],
);
const handleMouseEnter = useCallback(
(idx: number) => {
if (!dragState) return;
setDragState((prev) => (prev ? { ...prev, endIdx: idx } : null));
},
[dragState],
);
// document-level mouseup: 드래그 종료
useEffect(() => {
if (!dragState) return;
const handleMouseUp = () => {
const { startIdx, endIdx, direction } = dragState;
if (startIdx === endIdx) {
// 단일 클릭
toggleMmsi(vessels[startIdx].mmsi);
} else {
// 범위 선택
const min = Math.min(startIdx, endIdx);
const max = Math.max(startIdx, endIdx);
const newSet = new Set(selectedMmsis);
for (let i = min; i <= max; i++) {
const mmsi = vessels[i].mmsi;
if (direction === 'check') newSet.add(mmsi);
else newSet.delete(mmsi);
}
setMmsis(newSet);
}
setDragState(null);
};
document.addEventListener('mouseup', handleMouseUp);
return () => document.removeEventListener('mouseup', handleMouseUp);
}, [dragState, vessels, selectedMmsis, toggleMmsi, setMmsis]);
return (
<table style={{ ...STYLE_TABLE, userSelect: dragState ? 'none' : undefined }}>
<thead>
<tr>
<th style={STYLE_TH_CHECKBOX} />
<th style={STYLE_TH}></th>
<th style={STYLE_TH}></th>
<th style={STYLE_TH}></th>
<th style={STYLE_TH}>MMSI</th>
<th style={STYLE_TH}></th>
<th style={STYLE_TH}></th>
</tr>
</thead>
<tbody>
{vessels.map((v, idx) => {
const isSelected = selectedMmsis.has(v.mmsi);
const meta = VESSEL_TYPES[v.shipCode];
const inRange = dragState ? isInDragRange(idx, dragState) : false;
// 드래그 중 범위 내 행 → 예상 상태 미리보기
let rowBg: string | undefined;
if (inRange && dragState) {
rowBg = getDragHighlight(dragState.direction);
} else if (isSelected) {
rowBg = 'rgba(59,130,246,0.12)';
}
const tdStyle = getTdStyle(false); // 배경은 tr에서 관리
const stateBadgeStyle = getStateBadgeStyle(v.state.isFishing, v.state.isTransit);
const mmsiDisplay = String(v.mmsi);
const sogDisplay = v.sog !== null ? `${v.sog.toFixed(1)} kt` : '';
// 드래그 중 범위 내 체크 상태 미리보기
const previewChecked = inRange && dragState
? dragState.direction === 'check'
: isSelected;
return (
<tr
key={v.mmsi}
style={{ cursor: 'pointer', background: rowBg }}
onMouseDown={(e) => handleMouseDown(idx, e)}
onMouseEnter={() => handleMouseEnter(idx)}
>
<td style={tdStyle}>
<input
type="checkbox"
title="선택"
checked={previewChecked}
onChange={() => toggleMmsi(v.mmsi)}
onClick={(e) => e.stopPropagation()}
style={{ cursor: 'pointer' }}
/>
</td>
<td style={tdStyle}>
<span style={getDotStyle(meta.color)} />
{v.shipCode}
</td>
<td style={tdStyle}>{v.permitNo}</td>
<td style={tdStyle}>{v.name}</td>
<td style={tdStyle}>{mmsiDisplay}</td>
<td style={tdStyle}>{sogDisplay}</td>
<td style={tdStyle}>
<span style={stateBadgeStyle}>{v.state.label}</span>
</td>
</tr>
);
})}
</tbody>
</table>
);
}

파일 보기

@ -0,0 +1,410 @@
import { useMemo, useEffect, useCallback, type CSSProperties } from 'react';
import type { VesselSelectModalState } from '../../features/vesselSelect/hooks/useVesselSelectModal';
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from '../../entities/vessel/model/meta';
import { VesselSelectGrid } from './VesselSelectGrid';
import { ToggleButton, TextInput, Button } from '@wing/ui';
interface VesselSelectModalProps {
modal: VesselSelectModalState;
vessels: DerivedLegacyVessel[];
}
const STRIP_RE = /[\s\-.,_]/g;
function normalize(s: string): string {
return s.replace(STRIP_RE, '').toLowerCase();
}
function matchesQuery(v: DerivedLegacyVessel, nq: string): boolean {
if (normalize(v.permitNo).includes(nq)) return true;
if (normalize(v.name).includes(nq)) return true;
if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true;
if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true;
if (normalize(String(v.mmsi)).includes(nq)) return true;
return false;
}
const STATE_LABELS = ['조업', '항해', '정지', '저속', '미상'] as const;
const STATE_COLORS: Record<string, string> = {
: '#22C55E',
: '#3B82F6',
: '#64748B',
: '#EAB308',
: '#6B7280',
};
const STYLE_OVERLAY: CSSProperties = {
position: 'fixed',
inset: 0,
zIndex: 1050,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
};
const STYLE_BACKDROP: CSSProperties = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.35)',
pointerEvents: 'auto',
};
const STYLE_CONTENT: CSSProperties = {
position: 'relative',
display: 'flex',
flexDirection: 'column',
maxWidth: 720,
width: '95vw',
maxHeight: '57vh',
background: 'rgba(15,23,42,0.96)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(148,163,184,0.25)',
borderRadius: 12,
color: '#e2e8f0',
overflow: 'hidden',
pointerEvents: 'auto',
};
const STYLE_HEADER: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
borderBottom: '1px solid rgba(148,163,184,0.15)',
flexShrink: 0,
cursor: 'grab',
userSelect: 'none',
};
const STYLE_CLOSE_BTN: CSSProperties = {
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
padding: '2px 6px',
};
const STYLE_FILTERS: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: '8px 16px',
borderBottom: '1px solid rgba(148,163,184,0.1)',
flexShrink: 0,
};
const STYLE_FILTER_ROW: CSSProperties = {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 5,
};
const STYLE_FILTER_LABEL: CSSProperties = {
fontSize: 11,
color: '#64748b',
minWidth: 28,
flexShrink: 0,
};
const STYLE_DOT = (color: string): CSSProperties => ({
display: 'inline-block',
width: 7,
height: 7,
borderRadius: '50%',
background: color,
marginRight: 3,
verticalAlign: 'middle',
});
const STYLE_SEARCH: CSSProperties = {
padding: '6px 16px',
borderBottom: '1px solid rgba(148,163,184,0.1)',
flexShrink: 0,
};
const STYLE_GRID: CSSProperties = {
flex: 1,
overflowY: 'auto',
minHeight: 0,
};
const STYLE_DATE_BAR: CSSProperties = {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 6,
padding: '8px 16px',
borderTop: '1px solid rgba(148,163,184,0.1)',
flexShrink: 0,
fontSize: 11,
};
const STYLE_DATETIME_INPUT: CSSProperties = {
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 STYLE_FOOTER: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 16px',
borderTop: '1px solid rgba(148,163,184,0.15)',
flexShrink: 0,
};
const STYLE_FOOTER_SPACER: CSSProperties = { flex: 1 };
const STYLE_SEPARATOR: CSSProperties = {
width: 1,
height: 14,
background: 'rgba(148,163,184,0.2)',
flexShrink: 0,
};
export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) {
const {
isOpen,
close,
selectedMmsis,
toggleMmsi,
setMmsis,
selectAllFiltered,
clearAll,
searchQuery,
setSearchQuery,
shipCodeFilter,
toggleShipCode,
toggleAllShipCodes,
onlySailing,
setOnlySailing,
stateFilter,
toggleStateFilter,
toggleAllStates,
startTime,
endTime,
setStartTime,
setEndTime,
applyPresetDays,
isQuerying,
submitQuery,
position,
setPosition,
selectionWarning,
} = modal;
// ── 필터 ──
const filteredVessels = useMemo(() => {
let list = vessels;
if (shipCodeFilter.size > 0) {
list = list.filter((v) => shipCodeFilter.has(v.shipCode));
}
if (stateFilter.size > 0) {
list = list.filter((v) => stateFilter.has(v.state.label));
}
if (onlySailing) {
list = list.filter((v) => v.state.isFishing || v.state.isTransit);
}
const nq = searchQuery.length >= 2 ? normalize(searchQuery) : '';
if (nq) {
list = list.filter((v) => matchesQuery(v, nq));
}
return list;
}, [vessels, shipCodeFilter, stateFilter, onlySailing, searchQuery]);
// ── Escape 닫기 ──
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') close();
},
[close],
);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyDown]);
// ── 드래그 ──
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest('button')) return;
e.preventDefault();
const startX = e.clientX - position.x;
const startY = e.clientY - position.y;
document.body.style.cursor = 'grabbing';
const handleMove = (ev: PointerEvent) => {
setPosition({ x: ev.clientX - startX, y: ev.clientY - startY });
};
const handleUp = () => {
document.body.style.cursor = '';
document.removeEventListener('pointermove', handleMove);
document.removeEventListener('pointerup', handleUp);
};
document.addEventListener('pointermove', handleMove);
document.addEventListener('pointerup', handleUp);
},
[position, setPosition],
);
// ── 전체 선택 ──
const isAllSelected = filteredVessels.length > 0 && filteredVessels.every((v) => selectedMmsis.has(v.mmsi));
const handleSelectAllChange = useCallback(() => {
if (isAllSelected) clearAll();
else selectAllFiltered(filteredVessels);
}, [isAllSelected, clearAll, selectAllFiltered, filteredVessels]);
const handleContentClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
if (!isOpen) return null;
return (
<>
<div style={STYLE_BACKDROP} onClick={close} />
<div
style={{
...STYLE_OVERLAY,
}}
>
<div
style={{
...STYLE_CONTENT,
transform: `translate(${position.x}px, ${position.y}px)`,
}}
onClick={handleContentClick}
>
{/* 헤더 (드래그 핸들) */}
<div
style={STYLE_HEADER}
onPointerDown={handlePointerDown}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#64748b', fontSize: 12 }}></span>
<strong style={{ fontSize: 13 }}> </strong>
<span style={{ color: '#64748b', fontSize: 11 }}>({vessels.length})</span>
</div>
<button style={STYLE_CLOSE_BTN} onClick={close}>
</button>
</div>
{/* 업종 + 상태 필터 */}
<div style={STYLE_FILTERS}>
<div style={STYLE_FILTER_ROW}>
<span style={STYLE_FILTER_LABEL}></span>
<ToggleButton on={shipCodeFilter.size === VESSEL_TYPE_ORDER.length} onClick={() => toggleAllShipCodes(VESSEL_TYPE_ORDER)}>
</ToggleButton>
{VESSEL_TYPE_ORDER.map((code) => {
const meta = VESSEL_TYPES[code];
return (
<ToggleButton key={code} on={shipCodeFilter.has(code)} onClick={() => toggleShipCode(code)}>
<span style={STYLE_DOT(meta.color)} />
{code}
</ToggleButton>
);
})}
</div>
<div style={STYLE_FILTER_ROW}>
<span style={STYLE_FILTER_LABEL}></span>
<ToggleButton on={stateFilter.size === STATE_LABELS.length} onClick={() => toggleAllStates([...STATE_LABELS])}>
</ToggleButton>
{STATE_LABELS.map((label) => (
<ToggleButton key={label} on={stateFilter.has(label)} onClick={() => toggleStateFilter(label)}>
<span style={STYLE_DOT(STATE_COLORS[label])} />
{label}
</ToggleButton>
))}
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: '#94a3b8',
cursor: 'pointer',
marginLeft: 4,
}}
>
<input
type="checkbox"
checked={onlySailing}
onChange={(e) => setOnlySailing(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
</label>
</div>
</div>
{/* 검색 */}
<div style={STYLE_SEARCH}>
<TextInput placeholder="검색: 등록번호 / 선박명 / MMSI" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
</div>
{/* 그리드 */}
<div style={STYLE_GRID}>
<VesselSelectGrid vessels={filteredVessels} selectedMmsis={selectedMmsis} toggleMmsi={toggleMmsi} setMmsis={setMmsis} />
</div>
{/* 기간 설정 바 */}
<div style={STYLE_DATE_BAR}>
{[7, 14, 21, 28].map((d) => (
<Button key={d} variant="ghost" size="sm" onClick={() => applyPresetDays(d)}>
{d}
</Button>
))}
<div style={STYLE_SEPARATOR} />
<label style={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="datetime-local" value={startTime} onChange={(e) => setStartTime(e.target.value)} style={STYLE_DATETIME_INPUT} />
</label>
<label style={{ color: '#94a3b8', display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="datetime-local" value={endTime} onChange={(e) => setEndTime(e.target.value)} style={STYLE_DATETIME_INPUT} />
</label>
</div>
{/* 푸터 */}
<div style={STYLE_FOOTER}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: '#cbd5e1',
cursor: 'pointer',
}}
>
<input type="checkbox" checked={isAllSelected} onChange={handleSelectAllChange} style={{ cursor: 'pointer' }} />
({filteredVessels.length})
</label>
{selectionWarning && <span style={{ color: '#fca5a5', fontSize: 11 }}>{selectionWarning}</span>}
<div style={STYLE_FOOTER_SPACER} />
<span style={{ color: '#93c5fd', fontSize: 12 }}> {selectedMmsis.size}</span>
<Button variant="primary" size="md" disabled={selectedMmsis.size === 0} onClick={() => submitQuery(vessels)}>
{isQuerying ? '재조회' : '조회 시작'}
</Button>
</div>
</div>
</div>
</>
);
}

파일 보기

@ -84,6 +84,7 @@ function readPermittedListXlsx(filePath) {
const prev = byPermitNo.get(permitNo); const prev = byPermitNo.get(permitNo);
if (prev) { if (prev) {
if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi); if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi);
if (!prev.mmsi && mmsi) prev.mmsi = mmsi;
continue; continue;
} }
@ -101,6 +102,7 @@ function readPermittedListXlsx(filePath) {
workTerm2: toStr(r.work_term2), workTerm2: toStr(r.work_term2),
quota: toStr(r.quota), quota: toStr(r.quota),
shipCode: toStr(r.ship_code), shipCode: toStr(r.ship_code),
mmsi: mmsi,
mmsiList: mmsi ? [mmsi] : [], mmsiList: mmsi ? [mmsi] : [],
sources: { permittedList: true, checklist: false, fleet906: false }, sources: { permittedList: true, checklist: false, fleet906: false },
ownerCn: null, ownerCn: null,
@ -224,6 +226,7 @@ async function main() {
workTerm2: "", workTerm2: "",
quota: "", quota: "",
shipCode: c.shipCode, shipCode: c.shipCode,
mmsi: null,
mmsiList: [], mmsiList: [],
sources: { permittedList: false, checklist: true, fleet906: false }, sources: { permittedList: false, checklist: true, fleet906: false },
ownerCn: c.ownerCn || null, ownerCn: c.ownerCn || null,