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.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";
|
||||||
|
|||||||
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 (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,
|
||||||
|
|||||||
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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
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);
|
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,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user