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:
부모
81fb4a2bca
커밋
baf827657e
@ -19,3 +19,4 @@
|
||||
@import "./styles/components/weather.css";
|
||||
@import "./styles/components/weather-overlay.css";
|
||||
@import "./styles/components/announcement.css";
|
||||
@import "./styles/components/vessel-select-modal.css";
|
||||
|
||||
152
apps/web/src/app/styles/components/vessel-select-modal.css
Normal file
152
apps/web/src/app/styles/components/vessel-select-modal.css
Normal file
@ -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 (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 || []) {
|
||||
if (!Number.isFinite(m)) continue;
|
||||
const prev = byMmsi.get(m);
|
||||
if (!prev) {
|
||||
if (byMmsi.has(m)) continue;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ export type LegacyVesselInfo = {
|
||||
shipCode: string; // PT | PT-S | GN | OT | PS | FC | ...
|
||||
ton: number | null;
|
||||
callSign: string;
|
||||
mmsi: number | null;
|
||||
mmsiList: number[];
|
||||
workSeaArea: 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 */
|
||||
|
||||
@ -58,6 +58,7 @@ function makeLegacy(
|
||||
shipNameCn: null,
|
||||
ton: 100,
|
||||
callSign: '',
|
||||
mmsi: o.mmsiList[0] ?? null,
|
||||
workSeaArea: '서해',
|
||||
workTerm1: '2025-01-01',
|
||||
workTerm2: '2025-12-31',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { DISPLAY_TZ } from '../../../shared/lib/datetime';
|
||||
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';
|
||||
|
||||
@ -31,14 +31,14 @@ function calcSpeedKnots(track: ProcessedTrack, index: number): number {
|
||||
}
|
||||
|
||||
/** 포인트별 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 rows: string[] = [header.join(',')];
|
||||
|
||||
const mmsi = ctx?.mmsi ?? '';
|
||||
|
||||
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++) {
|
||||
rows.push(
|
||||
[
|
||||
@ -57,7 +57,7 @@ export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext
|
||||
}
|
||||
|
||||
/** 선박별 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 = [
|
||||
'mmsi',
|
||||
'shipName',
|
||||
@ -83,23 +83,29 @@ export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext
|
||||
];
|
||||
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) {
|
||||
const firstTs = track.timestampsMs[0] ?? 0;
|
||||
const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0;
|
||||
const info = track.chnPrmShipInfo;
|
||||
const mv = multiVesselMap?.get(track.targetId);
|
||||
|
||||
rows.push(
|
||||
[
|
||||
escCsv(ctx?.mmsi ?? track.targetId),
|
||||
escCsv(mv?.mmsi ?? 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(mv?.vesselType ?? ctx?.vesselType ?? ''),
|
||||
escCsv(mv?.ownerCn ?? ctx?.ownerCn),
|
||||
escCsv(mv?.ownerRoman ?? ctx?.ownerRoman),
|
||||
escCsv(mv?.permitNo ?? ctx?.permitNo),
|
||||
escCsv(mv?.pairPermitNo ?? ctx?.pairPermitNo),
|
||||
escCsv(mv?.ton ?? ctx?.ton),
|
||||
escCsv(mv?.callSign ?? ctx?.callSign),
|
||||
escCsv(mv?.workSeaArea ?? ctx?.workSeaArea),
|
||||
escCsv(track.nationalCode),
|
||||
escCsv(track.stats.totalDistanceNm),
|
||||
escCsv(track.stats.avgSpeed),
|
||||
@ -130,12 +136,12 @@ function downloadCsv(csvContent: string, filename: string): void {
|
||||
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 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(() => {
|
||||
downloadCsv(buildStaticCsv(tracks, ctx), `track-vessel-${ts}.csv`);
|
||||
downloadCsv(buildStaticCsv(tracks, ctx, multiCtx), `track-vessel-${ts}.csv`);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@ -64,6 +64,25 @@ export interface TrackQueryContext {
|
||||
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 {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
|
||||
@ -120,16 +120,16 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
|
||||
async function fetchV2Tracks(
|
||||
startTimeIso: string,
|
||||
endTimeIso: string,
|
||||
mmsi: number,
|
||||
isPermitted: boolean,
|
||||
mmsis: number[],
|
||||
hasPermitted: boolean,
|
||||
): Promise<ProcessedTrack[]> {
|
||||
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim();
|
||||
|
||||
const requestBody = {
|
||||
startTime: startTimeIso,
|
||||
endTime: endTimeIso,
|
||||
vessels: [String(mmsi)],
|
||||
includeChnPrmShip: isPermitted,
|
||||
vessels: mmsis.map(String),
|
||||
includeChnPrmShip: hasPermitted,
|
||||
};
|
||||
|
||||
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
|
||||
@ -156,9 +156,20 @@ async function fetchV2Tracks(
|
||||
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
|
||||
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);
|
||||
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);
|
||||
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 { getTracksTimeRange } from '../lib/adapters';
|
||||
import type { ProcessedTrack, TrackQueryContext } from '../model/track.types';
|
||||
import { queryTrackByDateRange } from '../services/trackQueryService';
|
||||
import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types';
|
||||
import { queryMultiTrack, queryTrackByDateRange } from '../services/trackQueryService';
|
||||
import { useTrackPlaybackStore } from './trackPlaybackStore';
|
||||
|
||||
export type TrackQueryStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
@ -16,6 +16,7 @@ interface TrackQueryState {
|
||||
renderEpoch: number;
|
||||
lastQueryKey: string | null;
|
||||
queryContext: TrackQueryContext | null;
|
||||
multiQueryContext: MultiTrackQueryContext | null;
|
||||
showPoints: boolean;
|
||||
showVirtualShip: boolean;
|
||||
showLabels: boolean;
|
||||
@ -23,6 +24,7 @@ interface TrackQueryState {
|
||||
hideLiveShips: boolean;
|
||||
|
||||
beginQuery: (queryKey: string, context?: TrackQueryContext) => void;
|
||||
beginMultiQuery: (queryKey: string, ctx: MultiTrackQueryContext) => Promise<void>;
|
||||
applyTracksSuccess: (tracks: ProcessedTrack[], queryKey?: string | null) => void;
|
||||
applyQueryError: (error: string, queryKey?: string | null) => void;
|
||||
closeQuery: () => void;
|
||||
@ -53,6 +55,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
||||
renderEpoch: 0,
|
||||
lastQueryKey: null,
|
||||
queryContext: null,
|
||||
multiQueryContext: null,
|
||||
showPoints: true,
|
||||
showVirtualShip: true,
|
||||
showLabels: true,
|
||||
@ -70,11 +73,48 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
||||
queryState: 'loading',
|
||||
renderEpoch: state.renderEpoch + 1,
|
||||
lastQueryKey: queryKey,
|
||||
hideLiveShips: false,
|
||||
hideLiveShips: true,
|
||||
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) => {
|
||||
const currentQueryKey = get().lastQueryKey;
|
||||
if (queryKey != null && queryKey !== currentQueryKey) {
|
||||
@ -146,11 +186,21 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
||||
renderEpoch: state.renderEpoch + 1,
|
||||
lastQueryKey: null,
|
||||
queryContext: null,
|
||||
multiQueryContext: null,
|
||||
hideLiveShips: false,
|
||||
}));
|
||||
},
|
||||
|
||||
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;
|
||||
if (!ctx) return;
|
||||
|
||||
@ -235,6 +285,7 @@ export const useTrackQueryStore = create<TrackQueryState>()((set, get) => ({
|
||||
renderEpoch: state.renderEpoch + 1,
|
||||
lastQueryKey: null,
|
||||
queryContext: null,
|
||||
multiQueryContext: null,
|
||||
showPoints: true,
|
||||
showVirtualShip: true,
|
||||
showLabels: true,
|
||||
|
||||
273
apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts
Normal file
273
apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
3
apps/web/src/features/vesselSelect/index.ts
Normal file
3
apps/web/src/features/vesselSelect/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type { VesselDescriptor } from './model/types';
|
||||
export { MAX_VESSEL_SELECT } from './model/types';
|
||||
export { useVesselSelectModal } from './hooks/useVesselSelectModal';
|
||||
19
apps/web/src/features/vesselSelect/model/types.ts
Normal file
19
apps/web/src/features/vesselSelect/model/types.ts
Normal file
@ -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 { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel";
|
||||
import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
|
||||
import { useVesselSelectModal } from "../../features/vesselSelect";
|
||||
import { VesselSelectModal } from "../../widgets/vesselSelect/VesselSelectModal";
|
||||
import {
|
||||
buildLegacyHitMap,
|
||||
computeCountsByType,
|
||||
@ -67,6 +69,9 @@ export function DashboardPage() {
|
||||
// ── Announcement popup ──
|
||||
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
|
||||
|
||||
// ── Vessel select modal (multi-track) ──
|
||||
const vesselSelectModal = useVesselSelectModal();
|
||||
|
||||
// ── Data fetching ──
|
||||
const { data: zones, error: zonesError } = useZones();
|
||||
const { data: legacyData, error: legacyError } = useLegacyVessels();
|
||||
@ -351,6 +356,7 @@ export function DashboardPage() {
|
||||
onToggleTheme={toggleTheme}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
onMenuToggle={() => setIsSidebarOpen((v) => !v)}
|
||||
onOpenMultiTrack={vesselSelectModal.open}
|
||||
/>
|
||||
|
||||
<DashboardSidebar
|
||||
@ -442,7 +448,13 @@ export function DashboardPage() {
|
||||
freeCamera={state.freeCamera}
|
||||
oceanMapSettings={state.oceanMapSettings}
|
||||
/>
|
||||
<GlobalTrackReplayPanel />
|
||||
<GlobalTrackReplayPanel
|
||||
isVesselListOpen={vesselSelectModal.isOpen}
|
||||
onToggleVesselList={() => {
|
||||
if (vesselSelectModal.isOpen) vesselSelectModal.close();
|
||||
else vesselSelectModal.reopen();
|
||||
}}
|
||||
/>
|
||||
<WeatherPanel
|
||||
snapshot={weather.snapshot}
|
||||
isLoading={weather.isLoading}
|
||||
@ -465,6 +477,9 @@ export function DashboardPage() {
|
||||
{hasUnread && (
|
||||
<AnnouncementModal announcements={unreadAnnouncements} onConfirm={acknowledge} />
|
||||
)}
|
||||
{vesselSelectModal.isOpen && (
|
||||
<VesselSelectModal modal={vesselSelectModal} vessels={legacyVesselsAll} />
|
||||
)}
|
||||
{imageModal && (
|
||||
<ShipImageModal
|
||||
images={imageModal.images}
|
||||
|
||||
@ -3,7 +3,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { type PickingInfo } from '@deck.gl/core';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/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 type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
@ -20,6 +20,8 @@ import {
|
||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
||||
import {
|
||||
FLAT_LEGACY_HALO_RADIUS,
|
||||
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||
HALO_BREATHE_PERIOD_MS,
|
||||
HALO_BREATHE_SELECTED_R_MIN,
|
||||
HALO_BREATHE_SELECTED_R_MAX,
|
||||
@ -31,6 +33,12 @@ import {
|
||||
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
|
||||
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(
|
||||
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
||||
@ -308,30 +316,35 @@ export function useDeckLayers(
|
||||
const now = Date.now();
|
||||
let updated = mercatorLayersRef.current;
|
||||
|
||||
// 1. 알람 맥동 (기존)
|
||||
// 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션
|
||||
if (hasAlarms) {
|
||||
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',
|
||||
data: alarmTargets,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
filled: true,
|
||||
stroked: false,
|
||||
radiusUnits: 'pixels',
|
||||
getRadius: (d) => {
|
||||
billboard: true,
|
||||
iconAtlas: ALARM_RING_ICON_URL,
|
||||
iconMapping: ALARM_RING_ICON_MAPPING,
|
||||
getIcon: () => 'ring',
|
||||
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);
|
||||
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);
|
||||
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],
|
||||
updateTriggers: { getRadius: [normalR, hoverR] },
|
||||
updateTriggers: {
|
||||
getSize: [selectedMmsi],
|
||||
},
|
||||
});
|
||||
updated = updated.map((l) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@ -28,12 +28,41 @@ import { clampNumber } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
|
||||
// ── Alarm pulse animation constants ──
|
||||
const ALARM_PULSE_R_MIN = 8;
|
||||
const ALARM_PULSE_R_MAX = 14;
|
||||
const ALARM_PULSE_R_HOVER_MIN = 12;
|
||||
const ALARM_PULSE_R_HOVER_MAX = 18;
|
||||
// Offset from outline radius so pulse ring never overlaps the outline stroke
|
||||
const ALARM_PULSE_OFFSET_MIN = 5; // px offset from base circle at rest
|
||||
const ALARM_PULSE_OFFSET_MAX = 11; // px offset at peak
|
||||
const ALARM_PULSE_HOVER_OFFSET_MIN = 7;
|
||||
const ALARM_PULSE_HOVER_OFFSET_MAX = 14;
|
||||
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) */
|
||||
export function useGlobeShipLayers(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -372,10 +401,12 @@ export function useGlobeShipLayers(
|
||||
filter: ['==', ['get', 'alarmed'], 1] as never,
|
||||
layout: { visibility },
|
||||
paint: {
|
||||
'circle-radius': ALARM_PULSE_R_MIN,
|
||||
'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
||||
'circle-opacity': 0.35,
|
||||
'circle-stroke-width': 0,
|
||||
'circle-radius': buildAlarmPulseRadiusExpr(ALARM_PULSE_OFFSET_MIN),
|
||||
'circle-color': 'transparent',
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
||||
'circle-stroke-opacity': 0.7,
|
||||
'circle-stroke-width': 1.5,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
before,
|
||||
@ -701,16 +732,16 @@ export function useGlobeShipLayers(
|
||||
return;
|
||||
}
|
||||
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 hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN);
|
||||
const normalOff = ALARM_PULSE_OFFSET_MIN + t * (ALARM_PULSE_OFFSET_MAX - ALARM_PULSE_OFFSET_MIN);
|
||||
const hoverOff = ALARM_PULSE_HOVER_OFFSET_MIN + t * (ALARM_PULSE_HOVER_OFFSET_MAX - ALARM_PULSE_HOVER_OFFSET_MIN);
|
||||
try {
|
||||
if (map.getLayer('ships-globe-alarm-pulse')) {
|
||||
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
|
||||
'case',
|
||||
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
|
||||
hoverR,
|
||||
normalR,
|
||||
] as never);
|
||||
map.setPaintProperty(
|
||||
'ships-globe-alarm-pulse',
|
||||
'circle-radius',
|
||||
buildAlarmPulseRadiusCaseExpr(normalOff, hoverOff),
|
||||
);
|
||||
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-stroke-opacity', 0.4 + t * 0.5);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@ -41,6 +41,11 @@ import {
|
||||
SPEED_THRESHOLD_KN,
|
||||
} 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 {
|
||||
@ -455,32 +460,33 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
||||
}
|
||||
}
|
||||
|
||||
/* ─ alarm pulse + badge ─ */
|
||||
/* ─ alarm pulse (IconLayer + SVG ring) + badge ─ */
|
||||
const alarmTargets = ctx.alarmTargets ?? [];
|
||||
const alarmMap = ctx.alarmMmsiMap;
|
||||
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
|
||||
const pulseR = ctx.alarmPulseRadius ?? 8;
|
||||
const pulseHR = ctx.alarmPulseHoverRadius ?? 12;
|
||||
const pulseSize = ctx.alarmPulseRadius ?? 40;
|
||||
const pulseHoverSize = ctx.alarmPulseHoverRadius ?? 48;
|
||||
layers.push(
|
||||
new ScatterplotLayer<AisTarget>({
|
||||
new IconLayer<AisTarget>({
|
||||
id: 'alarm-pulse',
|
||||
data: alarmTargets,
|
||||
pickable: false,
|
||||
billboard: false,
|
||||
billboard: true,
|
||||
parameters: overlayParams,
|
||||
filled: true,
|
||||
stroked: false,
|
||||
radiusUnits: 'pixels',
|
||||
getRadius: (d) => {
|
||||
iconAtlas: ALARM_RING_ICON_URL,
|
||||
iconMapping: { ring: { x: 0, y: 0, width: 64, height: 64, mask: true } },
|
||||
getIcon: () => 'ring',
|
||||
sizeUnits: 'pixels',
|
||||
getSize: (d) => {
|
||||
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);
|
||||
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],
|
||||
updateTriggers: { getRadius: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] },
|
||||
updateTriggers: { getSize: [pulseSize, pulseHoverSize, ctx.selectedMmsi, ctx.shipHighlightSet] },
|
||||
}),
|
||||
);
|
||||
layers.push(
|
||||
|
||||
@ -15,6 +15,7 @@ interface Props {
|
||||
onToggleTheme?: () => void;
|
||||
isSidebarOpen?: boolean;
|
||||
onMenuToggle?: () => void;
|
||||
onOpenMultiTrack?: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
{/* 항상 표시: 시계 + 테마 + 사용자 */}
|
||||
|
||||
@ -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 { useTrackQueryStore } from '../../features/trackReplay/stores/trackQueryStore';
|
||||
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 {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '--';
|
||||
@ -12,14 +15,12 @@ function formatDateTime(ms: number): string {
|
||||
)}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** ms → datetime-local input value (KST = UTC+9) */
|
||||
function toDateTimeLocalKST(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '';
|
||||
const kstDate = new Date(ms + 9 * 3600_000);
|
||||
return kstDate.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/** datetime-local value (KST) → ISO string */
|
||||
function fromDateTimeLocalKST(value: string): string {
|
||||
return `${value}:00+09:00`;
|
||||
}
|
||||
@ -44,46 +45,18 @@ const btnBase: React.CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
export function GlobalTrackReplayPanel() {
|
||||
const PANEL_WIDTH = 420;
|
||||
const PANEL_MARGIN = 12;
|
||||
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 };
|
||||
interface GlobalTrackReplayPanelProps {
|
||||
isVesselListOpen?: boolean;
|
||||
onToggleVesselList?: () => void;
|
||||
}
|
||||
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 isLoading = useTrackQueryStore((state) => state.isLoading);
|
||||
const error = useTrackQueryStore((state) => state.error);
|
||||
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 showLabels = useTrackQueryStore((state) => state.showLabels);
|
||||
const showTrail = useTrackQueryStore((state) => state.showTrail);
|
||||
@ -94,6 +67,7 @@ export function GlobalTrackReplayPanel() {
|
||||
const setHideLiveShips = useTrackQueryStore((state) => state.setHideLiveShips);
|
||||
const closeTrackQuery = useTrackQueryStore((state) => state.closeQuery);
|
||||
const requery = useTrackQueryStore((state) => state.requery);
|
||||
const toggleVesselEnabled = useTrackQueryStore((state) => state.toggleVesselEnabled);
|
||||
|
||||
const isPlaying = useTrackPlaybackStore((state) => state.isPlaying);
|
||||
const currentTime = useTrackPlaybackStore((state) => state.currentTime);
|
||||
@ -120,92 +94,48 @@ export function GlobalTrackReplayPanel() {
|
||||
const setEditStartTime = (v: string) => setEditState((prev) => ({ ...prev, start: v }));
|
||||
const setEditEndTime = (v: string) => setEditState((prev) => ({ ...prev, end: v }));
|
||||
|
||||
const [requeryWarning, setRequeryWarning] = useState<string | null>(null);
|
||||
const handleRequery = useCallback(() => {
|
||||
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));
|
||||
}, [editStartTime, editEndTime, requery]);
|
||||
|
||||
const handleExportCsv = useCallback(() => {
|
||||
if (tracks.length === 0) return;
|
||||
exportTrackCsv(tracks, queryContext);
|
||||
}, [tracks, queryContext]);
|
||||
exportTrackCsv(tracks, queryContext, multiQueryContext);
|
||||
}, [tracks, queryContext, multiQueryContext]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (endTime <= startTime) return 0;
|
||||
return ((currentTime - startTime) / (endTime - startTime)) * 100;
|
||||
}, [startTime, endTime, currentTime]);
|
||||
|
||||
const isVisible = isLoading || tracks.length > 0 || !!error;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
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],
|
||||
);
|
||||
const isMultiMode = multiQueryContext != null;
|
||||
const vesselCount = tracks.length;
|
||||
const [isVesselListExpanded, setIsVesselListExpanded] = useState(false);
|
||||
const hasRequeryContext = isMultiMode || !!queryContext;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: PANEL_WIDTH,
|
||||
bottom: 12,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'min(95vw, 700px)',
|
||||
background: 'rgba(15,23,42,0.94)',
|
||||
border: '1px solid rgba(148,163,184,0.35)',
|
||||
borderRadius: 12,
|
||||
@ -217,23 +147,31 @@ export function GlobalTrackReplayPanel() {
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onPointerDown={handleHeaderPointerDown}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<strong style={{ fontSize: 13 }}>
|
||||
Track Replay{vesselCount > 0 ? ` (${vesselCount}척)` : ''}
|
||||
</strong>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{onToggleVesselList && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleVesselList}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
fontSize: 11,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${isVesselListOpen ? 'rgba(96,165,250,0.7)' : 'rgba(96,165,250,0.35)'}`,
|
||||
background: isVesselListOpen ? 'rgba(37,99,235,0.35)' : 'rgba(37,99,235,0.12)',
|
||||
color: isVesselListOpen ? '#bfdbfe' : '#93c5fd',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13 }}>Track Replay</strong>
|
||||
선박 목록
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeTrackQuery()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '4px 8px',
|
||||
@ -247,73 +185,41 @@ export function GlobalTrackReplayPanel() {
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div>
|
||||
) : null}
|
||||
|
||||
{error ? <div style={{ marginBottom: 8, color: '#fca5a5', fontSize: 12 }}>{error}</div> : null}
|
||||
{requeryWarning ? <div style={{ marginBottom: 8, color: '#fbbf24', fontSize: 12 }}>{requeryWarning}</div> : null}
|
||||
{isLoading ? <div style={{ marginBottom: 8, fontSize: 12 }}>항적 조회 중...</div> : null}
|
||||
|
||||
{/* Vessel count */}
|
||||
<div style={{ fontSize: 11, color: '#93c5fd', marginBottom: 6 }}>
|
||||
선박 {tracks.length}척
|
||||
</div>
|
||||
{/* Vessel list — dynamic layout */}
|
||||
{isMultiMode && vesselCount > 0 ? (
|
||||
<VesselListSection
|
||||
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 */}
|
||||
<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}
|
||||
/>
|
||||
<input type="datetime-local" title="시작 시각" value={editStartTime} onChange={(e) => setEditStartTime(e.target.value)} disabled={isLoading || !hasRequeryContext} 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}
|
||||
/>
|
||||
<input type="datetime-local" title="종료 시각" value={editEndTime} onChange={(e) => setEditEndTime(e.target.value)} disabled={isLoading || !hasRequeryContext} 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 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' }}>
|
||||
재조회
|
||||
</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',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
@ -321,40 +227,17 @@ export function GlobalTrackReplayPanel() {
|
||||
|
||||
{/* Playback controls */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isPlaying ? pause() : play())}
|
||||
disabled={tracks.length === 0}
|
||||
style={btnBase}
|
||||
>
|
||||
<button type="button" onClick={() => (isPlaying ? pause() : play())} disabled={tracks.length === 0} style={btnBase}>
|
||||
{isPlaying ? '일시정지' : '재생'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stop()}
|
||||
disabled={tracks.length === 0}
|
||||
style={btnBase}
|
||||
>
|
||||
<button type="button" onClick={() => stop()} disabled={tracks.length === 0} style={btnBase}>
|
||||
정지
|
||||
</button>
|
||||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
배속
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<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' }}>
|
||||
{TRACK_PLAYBACK_SPEED_OPTIONS.map((speed) => (
|
||||
<option key={speed} value={speed}>
|
||||
{speed}x
|
||||
</option>
|
||||
<option key={speed} value={speed}>{speed}x</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
@ -362,15 +245,7 @@ export function GlobalTrackReplayPanel() {
|
||||
|
||||
{/* Timeline slider */}
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
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}
|
||||
/>
|
||||
<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} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#94a3b8' }}>
|
||||
<span>{formatDateTime(currentTime)}</span>
|
||||
<span>{Math.max(0, Math.min(100, progress)).toFixed(1)}%</span>
|
||||
@ -379,24 +254,57 @@ export function GlobalTrackReplayPanel() {
|
||||
|
||||
{/* Display toggles */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 6, fontSize: 12 }}>
|
||||
<label>
|
||||
<input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> 포인트
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={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>
|
||||
<label><input type="checkbox" checked={showPoints} onChange={(event) => setShowPoints(event.target.checked)} /> 포인트</label>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
203
apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx
Normal file
203
apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
410
apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx
Normal file
410
apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx
Normal file
@ -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);
|
||||
if (prev) {
|
||||
if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi);
|
||||
if (!prev.mmsi && mmsi) prev.mmsi = mmsi;
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -101,6 +102,7 @@ function readPermittedListXlsx(filePath) {
|
||||
workTerm2: toStr(r.work_term2),
|
||||
quota: toStr(r.quota),
|
||||
shipCode: toStr(r.ship_code),
|
||||
mmsi: mmsi,
|
||||
mmsiList: mmsi ? [mmsi] : [],
|
||||
sources: { permittedList: true, checklist: false, fleet906: false },
|
||||
ownerCn: null,
|
||||
@ -224,6 +226,7 @@ async function main() {
|
||||
workTerm2: "",
|
||||
quota: "",
|
||||
shipCode: c.shipCode,
|
||||
mmsi: null,
|
||||
mmsiList: [],
|
||||
sources: { permittedList: false, checklist: true, fleet906: false },
|
||||
ownerCn: c.ownerCn || null,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user