Merge pull request 'release: 2026-03-08 (5건 커밋)' (#52) from develop into main
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 35s
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 35s
Reviewed-on: #52
This commit is contained in:
커밋
ff6a7cc710
@ -81,5 +81,8 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -1,18 +1,4 @@
|
|||||||
// ── Shared map constants ──
|
// ── Shared map constants ──
|
||||||
// Moved from widgets/map3d/constants.ts to resolve FSD layer violation
|
|
||||||
// (features/ must not import from widgets/).
|
|
||||||
|
|
||||||
export const SHIP_ICON_MAPPING = {
|
|
||||||
ship: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 128,
|
|
||||||
height: 128,
|
|
||||||
anchorX: 64,
|
|
||||||
anchorY: 64,
|
|
||||||
mask: true,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const DEPTH_DISABLED_PARAMS = {
|
export const DEPTH_DISABLED_PARAMS = {
|
||||||
depthCompare: 'always',
|
depthCompare: 'always',
|
||||||
|
|||||||
187
apps/web/src/shared/lib/map/shipKind.ts
Normal file
187
apps/web/src/shared/lib/map/shipKind.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
// ── 선종(Ship Kind) 상수 + SVG 아이콘 생성 ──
|
||||||
|
// gc-wing-simple의 SVG 기반 선종별 아이콘 시스템을 도입.
|
||||||
|
// 기타 AIS: 선종별 색상 + 이동(화살표)/정지(원형) 분리.
|
||||||
|
// 대상 선박: legacy code 색상 + 약간 더 큰 SVG + 흰색 테두리.
|
||||||
|
|
||||||
|
import { LEGACY_CODE_COLORS_RGB, rgbToHex, type Rgb } from './palette';
|
||||||
|
|
||||||
|
// ── 선종 상수 (8종) ──
|
||||||
|
|
||||||
|
export const SIGNAL_KIND = {
|
||||||
|
FISHING: '000020',
|
||||||
|
KCGV: '000021',
|
||||||
|
PASSENGER: '000022',
|
||||||
|
CARGO: '000023',
|
||||||
|
TANKER: '000024',
|
||||||
|
GOV: '000025',
|
||||||
|
NORMAL: '000027',
|
||||||
|
BUOY: '000028',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SignalKindCode = (typeof SIGNAL_KIND)[keyof typeof SIGNAL_KIND] | string;
|
||||||
|
|
||||||
|
/** 선종별 한글 라벨 */
|
||||||
|
export const SHIP_KIND_LABELS: Record<string, string> = {
|
||||||
|
'000020': '어선',
|
||||||
|
'000021': '경비함정',
|
||||||
|
'000022': '여객선',
|
||||||
|
'000023': '화물선',
|
||||||
|
'000024': '유조선',
|
||||||
|
'000025': '관공선',
|
||||||
|
'000027': '일반',
|
||||||
|
'000028': '부이',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 선종별 범례/UI 색상 (hex) */
|
||||||
|
export const SHIP_KIND_COLORS: Record<string, string> = {
|
||||||
|
'000020': '#00C853',
|
||||||
|
'000021': '#FF5722',
|
||||||
|
'000022': '#2196F3',
|
||||||
|
'000023': '#9C27B0',
|
||||||
|
'000024': '#F44336',
|
||||||
|
'000025': '#FF9800',
|
||||||
|
'000027': '#607D8B',
|
||||||
|
'000028': '#795548',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 정렬된 선종 코드 목록 (범례 표시 순서) */
|
||||||
|
export const SHIP_KIND_ORDER = [
|
||||||
|
'000020', '000021', '000022', '000023',
|
||||||
|
'000024', '000025', '000027', '000028',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ── SVG 아이콘 생성기 ──
|
||||||
|
|
||||||
|
const STROKE = 'rgba(0,0,0,0.6)';
|
||||||
|
const TARGET_STROKE = 'rgba(255,255,255,0.7)';
|
||||||
|
|
||||||
|
/** 이동 중 선박 SVG (화살표 형태, 32×48) */
|
||||||
|
export function makeMovingShipSvg(fill: string): string {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48"><path d="M16 2 L8 13 L4 28 L7 45 L25 45 L28 28 L24 13 Z" fill="${fill}" stroke="${STROKE}" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 정지 선박 SVG (원형, 16×16) */
|
||||||
|
export function makeStoppedShipSvg(fill: string): string {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="5.5" fill="${fill}" stroke="${STROKE}" stroke-width="1.2"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 부이 SVG (다색, 32×44) */
|
||||||
|
export function makeBuoySvg(): string {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="44" viewBox="0 0 32 44"><line x1="16" y1="10" x2="16" y2="38" stroke="#5D4037" stroke-width="2.5" stroke-linecap="round"/><line x1="10" y1="38" x2="22" y2="38" stroke="#5D4037" stroke-width="2" stroke-linecap="round"/><ellipse cx="16" cy="24" rx="9" ry="7" fill="#E53935" stroke="#333" stroke-width="1"/><rect x="8" y="22" width="16" height="4" rx="1" fill="#FDD835" opacity="0.85"/><rect x="14.5" y="8" width="3" height="10" fill="#666"/><circle cx="16" cy="7" r="3.5" fill="#FFC107" stroke="#444" stroke-width="0.8"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대상 선박 이동 SVG (36×52, 흰색 반투명 테두리) */
|
||||||
|
export function makeTargetMovingShipSvg(fill: string): string {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="52" viewBox="0 0 36 52"><path d="M18 2 L9 14 L5 30 L8 49 L28 49 L31 30 L27 14 Z" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="2" stroke-linejoin="round"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대상 선박 정지 SVG (20×20, 흰색 반투명 테두리) */
|
||||||
|
export function makeTargetStoppedShipSvg(fill: string): string {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" fill="${fill}" stroke="${TARGET_STROKE}" stroke-width="1.5"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDataUri(svg: string): string {
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deck.gl IconLayer getIcon 반환 타입 */
|
||||||
|
export interface ShipIconSpec {
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
anchorX: number;
|
||||||
|
anchorY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 기타 AIS 아이콘 스펙 사전 생성 (8종 × 2상태 + buoy) ──
|
||||||
|
|
||||||
|
const OTHER_ICON_SPECS: Record<string, ShipIconSpec> = {};
|
||||||
|
|
||||||
|
for (const code of SHIP_KIND_ORDER) {
|
||||||
|
const color = SHIP_KIND_COLORS[code] || '#607D8B';
|
||||||
|
|
||||||
|
if (code === '000028') {
|
||||||
|
OTHER_ICON_SPECS[`${code}-buoy`] = {
|
||||||
|
url: toDataUri(makeBuoySvg()),
|
||||||
|
width: 32, height: 44, anchorX: 16, anchorY: 22,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
OTHER_ICON_SPECS[`${code}-moving`] = {
|
||||||
|
url: toDataUri(makeMovingShipSvg(color)),
|
||||||
|
width: 32, height: 48, anchorX: 16, anchorY: 24,
|
||||||
|
};
|
||||||
|
OTHER_ICON_SPECS[`${code}-stopped`] = {
|
||||||
|
url: toDataUri(makeStoppedShipSvg(color)),
|
||||||
|
width: 16, height: 16, anchorX: 8, anchorY: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
const FALLBACK_MOVING: ShipIconSpec = OTHER_ICON_SPECS['000027-moving'];
|
||||||
|
const FALLBACK_STOPPED: ShipIconSpec = OTHER_ICON_SPECS['000027-stopped'];
|
||||||
|
|
||||||
|
// ── 대상 선박 아이콘 스펙 사전 생성 (7 legacyCode × 2상태) ──
|
||||||
|
|
||||||
|
const LEGACY_CODES = ['PT', 'PT-S', 'GN', 'OT', 'PS', 'FC', 'C21'] as const;
|
||||||
|
|
||||||
|
const TARGET_ICON_SPECS: Record<string, ShipIconSpec> = {};
|
||||||
|
|
||||||
|
for (const code of LEGACY_CODES) {
|
||||||
|
const rgb: Rgb = LEGACY_CODE_COLORS_RGB[code] || [100, 116, 139];
|
||||||
|
const hex = rgbToHex(rgb);
|
||||||
|
|
||||||
|
TARGET_ICON_SPECS[`${code}-moving`] = {
|
||||||
|
url: toDataUri(makeTargetMovingShipSvg(hex)),
|
||||||
|
width: 36, height: 52, anchorX: 18, anchorY: 26,
|
||||||
|
};
|
||||||
|
TARGET_ICON_SPECS[`${code}-stopped`] = {
|
||||||
|
url: toDataUri(makeTargetStoppedShipSvg(hex)),
|
||||||
|
width: 20, height: 20, anchorX: 10, anchorY: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback (FC 색상)
|
||||||
|
const TARGET_FALLBACK_MOVING: ShipIconSpec = TARGET_ICON_SPECS['FC-moving'];
|
||||||
|
const TARGET_FALLBACK_STOPPED: ShipIconSpec = TARGET_ICON_SPECS['FC-stopped'];
|
||||||
|
|
||||||
|
// ── SOG 기준 이동/정지 판단 (kn) ──
|
||||||
|
|
||||||
|
export const SPEED_THRESHOLD_KN = 1;
|
||||||
|
|
||||||
|
// ── 조회 함수 ──
|
||||||
|
|
||||||
|
/** 기타 AIS 아이콘 스펙 조회 */
|
||||||
|
export function getShipIconSpec(
|
||||||
|
signalKindCode: string | undefined | null,
|
||||||
|
sog: number | undefined | null,
|
||||||
|
): ShipIconSpec {
|
||||||
|
const code = signalKindCode || '000027';
|
||||||
|
if (code === '000028') return OTHER_ICON_SPECS['000028-buoy'] || FALLBACK_STOPPED;
|
||||||
|
|
||||||
|
const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN;
|
||||||
|
const key = `${code}-${isMoving ? 'moving' : 'stopped'}`;
|
||||||
|
return OTHER_ICON_SPECS[key] || (isMoving ? FALLBACK_MOVING : FALLBACK_STOPPED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대상 선박 아이콘 스펙 조회 */
|
||||||
|
export function getTargetShipIconSpec(
|
||||||
|
legacyShipCode: string | undefined | null,
|
||||||
|
sog: number | undefined | null,
|
||||||
|
): ShipIconSpec {
|
||||||
|
const code = legacyShipCode || 'FC';
|
||||||
|
const isMoving = Number.isFinite(sog) && (sog as number) > SPEED_THRESHOLD_KN;
|
||||||
|
const key = `${code}-${isMoving ? 'moving' : 'stopped'}`;
|
||||||
|
return TARGET_ICON_SPECS[key] || (isMoving ? TARGET_FALLBACK_MOVING : TARGET_FALLBACK_STOPPED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 선박 아이콘 회전각 (부이는 0, 나머지는 -cog) */
|
||||||
|
export function getShipIconAngle(
|
||||||
|
signalKindCode: string | undefined | null,
|
||||||
|
cog: number | undefined | null,
|
||||||
|
): number {
|
||||||
|
const code = signalKindCode || '000027';
|
||||||
|
if (code === '000028') return 0;
|
||||||
|
return -(Number.isFinite(cog) ? (cog as number) : 0);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
|
||||||
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
import { LEGACY_CODE_COLORS_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
|
||||||
|
import { SHIP_KIND_ORDER, SHIP_KIND_LABELS, SHIP_KIND_COLORS } from "../../shared/lib/map/shipKind";
|
||||||
|
|
||||||
export function MapLegend() {
|
export function MapLegend() {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
@ -25,23 +26,13 @@ export function MapLegend() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(속도)</div>
|
<div className="lt" style={{ marginTop: 8 }}>기타 AIS 선박(선종)</div>
|
||||||
<div className="li">
|
{SHIP_KIND_ORDER.filter((c) => c !== '000028').map((code) => (
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
|
<div key={code} className="li">
|
||||||
SOG ≥ 10 kt
|
<div className="ls" style={{ background: SHIP_KIND_COLORS[code], borderRadius: 999 }} />
|
||||||
</div>
|
{SHIP_KIND_LABELS[code]}
|
||||||
<div className="li">
|
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
|
|
||||||
1 ≤ SOG < 10 kt
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
|
|
||||||
SOG < 1 kt
|
|
||||||
</div>
|
|
||||||
<div className="li">
|
|
||||||
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
|
|
||||||
SOG unknown
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
<div className="lt" style={{ marginTop: 8 }}>CN Permit(업종)</div>
|
||||||
<div className="li">
|
<div className="li">
|
||||||
|
|||||||
@ -624,7 +624,7 @@ export function Map3D({
|
|||||||
useDeckLayers(
|
useDeckLayers(
|
||||||
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
|
mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef,
|
||||||
{
|
{
|
||||||
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
projection, settings, trackReplayDeckLayers: trackReplayRenderState.trackReplayDeckLayers, shipLayerData, shipData,
|
||||||
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
|
legacyHits, pairLinks, fcLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
|
|||||||
@ -14,10 +14,7 @@ const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer;
|
|||||||
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange;
|
||||||
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious;
|
||||||
|
|
||||||
// ── Ship icon mapping (Deck.gl IconLayer) ──
|
// Ship icon mapping removed — now using shipKind.ts SVG-based icons
|
||||||
// Canonical source: shared/lib/map/mapConstants.ts (re-exported for local usage)
|
|
||||||
|
|
||||||
export { SHIP_ICON_MAPPING } from '../../shared/lib/map/mapConstants';
|
|
||||||
|
|
||||||
// ── Ship constants ──
|
// ── Ship constants ──
|
||||||
|
|
||||||
@ -47,14 +44,20 @@ export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)';
|
|||||||
|
|
||||||
// ── Flat map icon sizes ──
|
// ── Flat map icon sizes ──
|
||||||
|
|
||||||
export const FLAT_SHIP_ICON_SIZE = 19;
|
export const FLAT_OTHER_SHIP_SIZE = 20;
|
||||||
export const FLAT_SHIP_ICON_SIZE_SELECTED = 28;
|
export const FLAT_TARGET_SHIP_SIZE = 26;
|
||||||
export const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25;
|
|
||||||
export const FLAT_LEGACY_HALO_RADIUS = 14;
|
export const FLAT_LEGACY_HALO_RADIUS = 14;
|
||||||
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
|
export const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18;
|
||||||
export const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16;
|
|
||||||
export const EMPTY_MMSI_SET = new Set<number>();
|
export const EMPTY_MMSI_SET = new Set<number>();
|
||||||
|
|
||||||
|
// ── 대상 선박 브리딩 애니메이션 ──
|
||||||
|
|
||||||
|
export const HALO_BREATHE_PERIOD_MS = 2000;
|
||||||
|
export const HALO_BREATHE_SELECTED_R_MIN = 16;
|
||||||
|
export const HALO_BREATHE_SELECTED_R_MAX = 22;
|
||||||
|
export const HALO_BREATHE_HIGHLIGHTED_R_MIN = 14;
|
||||||
|
export const HALO_BREATHE_HIGHLIGHTED_R_MAX = 19;
|
||||||
|
|
||||||
// ── Deck.gl view ID ──
|
// ── Deck.gl view ID ──
|
||||||
|
|
||||||
export const DECK_VIEW_ID = 'mapbox';
|
export const DECK_VIEW_ID = 'mapbox';
|
||||||
|
|||||||
@ -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';
|
||||||
@ -19,11 +19,26 @@ import {
|
|||||||
} from '../lib/tooltips';
|
} from '../lib/tooltips';
|
||||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
||||||
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories';
|
||||||
|
import {
|
||||||
|
FLAT_LEGACY_HALO_RADIUS,
|
||||||
|
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||||
|
HALO_BREATHE_PERIOD_MS,
|
||||||
|
HALO_BREATHE_SELECTED_R_MIN,
|
||||||
|
HALO_BREATHE_SELECTED_R_MAX,
|
||||||
|
HALO_BREATHE_HIGHLIGHTED_R_MIN,
|
||||||
|
HALO_BREATHE_HIGHLIGHTED_R_MAX,
|
||||||
|
} from '../constants';
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
|
||||||
// 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>,
|
||||||
@ -35,7 +50,6 @@ export function useDeckLayers(
|
|||||||
settings: Map3DSettings;
|
settings: Map3DSettings;
|
||||||
trackReplayDeckLayers: unknown[];
|
trackReplayDeckLayers: unknown[];
|
||||||
shipLayerData: AisTarget[];
|
shipLayerData: AisTarget[];
|
||||||
shipOverlayLayerData: AisTarget[];
|
|
||||||
shipData: AisTarget[];
|
shipData: AisTarget[];
|
||||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
pairLinks: PairLink[] | undefined;
|
pairLinks: PairLink[] | undefined;
|
||||||
@ -72,7 +86,7 @@ export function useDeckLayers(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData,
|
projection, settings, trackReplayDeckLayers, shipLayerData, shipData,
|
||||||
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges,
|
||||||
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive,
|
||||||
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
overlays, shipByMmsi, selectedMmsi, shipHighlightSet,
|
||||||
@ -98,9 +112,12 @@ export function useDeckLayers(
|
|||||||
}, [legacyTargets]);
|
}, [legacyTargets]);
|
||||||
|
|
||||||
const legacyOverlayTargets = useMemo(() => {
|
const legacyOverlayTargets = useMemo(() => {
|
||||||
if (shipHighlightSet.size === 0) return [];
|
if (shipHighlightSet.size === 0 && selectedMmsi == null) return [];
|
||||||
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
return legacyTargets.filter((target) =>
|
||||||
}, [legacyTargets, shipHighlightSet]);
|
shipHighlightSet.has(target.mmsi) ||
|
||||||
|
(selectedMmsi != null && target.mmsi === selectedMmsi),
|
||||||
|
);
|
||||||
|
}, [legacyTargets, shipHighlightSet, selectedMmsi]);
|
||||||
|
|
||||||
const alarmTargets = useMemo(() => {
|
const alarmTargets = useMemo(() => {
|
||||||
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
||||||
@ -134,7 +151,6 @@ export function useDeckLayers(
|
|||||||
|
|
||||||
const layers = buildMercatorDeckLayers({
|
const layers = buildMercatorDeckLayers({
|
||||||
shipLayerData,
|
shipLayerData,
|
||||||
shipOverlayLayerData,
|
|
||||||
legacyTargetsOrdered,
|
legacyTargetsOrdered,
|
||||||
legacyOverlayTargets,
|
legacyOverlayTargets,
|
||||||
legacyHits,
|
legacyHits,
|
||||||
@ -246,7 +262,6 @@ export function useDeckLayers(
|
|||||||
legacyTargetsOrdered,
|
legacyTargetsOrdered,
|
||||||
legacyHits,
|
legacyHits,
|
||||||
legacyOverlayTargets,
|
legacyOverlayTargets,
|
||||||
shipOverlayLayerData,
|
|
||||||
pairRangesInteractive,
|
pairRangesInteractive,
|
||||||
pairLinksInteractive,
|
pairLinksInteractive,
|
||||||
fcLinesInteractive,
|
fcLinesInteractive,
|
||||||
@ -275,9 +290,12 @@ export function useDeckLayers(
|
|||||||
onClickShipPhoto,
|
onClickShipPhoto,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mercator alarm pulse breathing animation (rAF)
|
// Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류
|
||||||
|
const hasAlarms = alarmMmsiMap && alarmMmsiMap.size > 0 && alarmTargets.length > 0;
|
||||||
|
const hasTargetOverlays = legacyOverlayTargets.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) {
|
if (projection !== 'mercator' || (!hasAlarms && !hasTargetOverlays)) {
|
||||||
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
alarmRafRef.current = 0;
|
alarmRafRef.current = 0;
|
||||||
return;
|
return;
|
||||||
@ -295,34 +313,75 @@ export function useDeckLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2;
|
const now = Date.now();
|
||||||
const normalR = 8 + t * 6;
|
let updated = mercatorLayersRef.current;
|
||||||
const hoverR = 12 + t * 6;
|
|
||||||
|
|
||||||
const pulseLyr = new ScatterplotLayer<AisTarget>({
|
// 1. 알람 맥동 — IconLayer + SVG ring, opacity/sizeScale uniform 애니메이션
|
||||||
|
if (hasAlarms) {
|
||||||
|
const tA = (Math.sin(now / 1500 * Math.PI * 2) + 1) / 2;
|
||||||
|
|
||||||
|
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) =>
|
||||||
const updated = mercatorLayersRef.current.map((l) =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 대상 선박 브리딩 링
|
||||||
|
if (hasTargetOverlays) {
|
||||||
|
const tH = (Math.sin(now / HALO_BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||||
|
const selR = HALO_BREATHE_SELECTED_R_MIN + tH * (HALO_BREATHE_SELECTED_R_MAX - HALO_BREATHE_SELECTED_R_MIN);
|
||||||
|
const hlR = HALO_BREATHE_HIGHLIGHTED_R_MIN + tH * (HALO_BREATHE_HIGHLIGHTED_R_MAX - HALO_BREATHE_HIGHLIGHTED_R_MIN);
|
||||||
|
const alpha = Math.round(155 + tH * 100);
|
||||||
|
|
||||||
|
const haloLyr = new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'legacy-halo-overlay',
|
||||||
|
data: legacyOverlayTargets,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: (d) => (selectedMmsi != null && d.mmsi === selectedMmsi ? selR : hlR),
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: 2.5,
|
||||||
|
getLineColor: (d) => {
|
||||||
|
if (selectedMmsi != null && d.mmsi === selectedMmsi) return [14, 234, 255, alpha] as [number, number, number, number];
|
||||||
|
return [245, 158, 11, alpha] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
updateTriggers: { getRadius: [selR, hlR], getLineColor: [alpha, selectedMmsi] },
|
||||||
|
});
|
||||||
|
updated = updated.map((l) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(l as any)?.id === 'legacy-halo-overlay' ? haloLyr : l,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
currentOverlay.setProps({ layers: updated } as never);
|
currentOverlay.setProps({ layers: updated } as never);
|
||||||
@ -336,7 +395,7 @@ export function useDeckLayers(
|
|||||||
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
alarmRafRef.current = 0;
|
alarmRafRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]);
|
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet, hasAlarms, hasTargetOverlays, legacyOverlayTargets]);
|
||||||
|
|
||||||
// Globe Deck overlay
|
// Globe Deck overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export function useGlobeShipHover(
|
|||||||
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
sog: isFiniteNumber(t.sog) ? t.sog : 0,
|
||||||
shipColor: getGlobeBaseShipColor({
|
shipColor: getGlobeBaseShipColor({
|
||||||
legacy: legacy?.shipCode || null,
|
legacy: legacy?.shipCode || null,
|
||||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
|
||||||
}),
|
}),
|
||||||
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
|
iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45),
|
||||||
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
|
iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7),
|
||||||
|
|||||||
@ -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>,
|
||||||
@ -84,9 +113,9 @@ export function useGlobeShipLayers(
|
|||||||
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
(isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3,
|
||||||
50, 420,
|
50, 420,
|
||||||
);
|
);
|
||||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
const baseSizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||||
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
|
// 대상 선박은 1.3x 배율 적용
|
||||||
// hover overlay 레이어가 확대 + z-priority를 담당
|
const sizeScale = legacy ? baseSizeScale * 1.3 : baseSizeScale;
|
||||||
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
||||||
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
||||||
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
||||||
@ -106,7 +135,7 @@ export function useGlobeShipLayers(
|
|||||||
isAnchored: isAnchored ? 1 : 0,
|
isAnchored: isAnchored ? 1 : 0,
|
||||||
shipColor: getGlobeBaseShipColor({
|
shipColor: getGlobeBaseShipColor({
|
||||||
legacy: legacy?.shipCode || null,
|
legacy: legacy?.shipCode || null,
|
||||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
|
||||||
}),
|
}),
|
||||||
iconSize3,
|
iconSize3,
|
||||||
iconSize7,
|
iconSize7,
|
||||||
@ -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
|
||||||
|
|||||||
@ -8,19 +8,14 @@ import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/mo
|
|||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { DashSeg, PairRangeCircle } from '../types';
|
import type { DashSeg, PairRangeCircle } from '../types';
|
||||||
import {
|
import {
|
||||||
SHIP_ICON_MAPPING,
|
FLAT_OTHER_SHIP_SIZE,
|
||||||
FLAT_SHIP_ICON_SIZE,
|
FLAT_TARGET_SHIP_SIZE,
|
||||||
FLAT_SHIP_ICON_SIZE_SELECTED,
|
|
||||||
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
|
||||||
FLAT_LEGACY_HALO_RADIUS,
|
FLAT_LEGACY_HALO_RADIUS,
|
||||||
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
||||||
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
|
||||||
EMPTY_MMSI_SET,
|
|
||||||
DEPTH_DISABLED_PARAMS,
|
DEPTH_DISABLED_PARAMS,
|
||||||
GLOBE_OVERLAY_PARAMS,
|
GLOBE_OVERLAY_PARAMS,
|
||||||
HALO_OUTLINE_COLOR,
|
HALO_OUTLINE_COLOR,
|
||||||
HALO_OUTLINE_COLOR_SELECTED,
|
HALO_OUTLINE_COLOR_SELECTED,
|
||||||
HALO_OUTLINE_COLOR_HIGHLIGHTED,
|
|
||||||
PAIR_RANGE_NORMAL_DECK,
|
PAIR_RANGE_NORMAL_DECK,
|
||||||
PAIR_RANGE_WARN_DECK,
|
PAIR_RANGE_WARN_DECK,
|
||||||
PAIR_LINE_NORMAL_DECK,
|
PAIR_LINE_NORMAL_DECK,
|
||||||
@ -38,8 +33,18 @@ import {
|
|||||||
FLEET_RANGE_LINE_DECK_HL,
|
FLEET_RANGE_LINE_DECK_HL,
|
||||||
FLEET_RANGE_FILL_DECK_HL,
|
FLEET_RANGE_FILL_DECK_HL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { getDisplayHeading, getShipColor } from './shipUtils';
|
import { getDisplayHeading } from './shipUtils';
|
||||||
import { getCachedShipIcon } from './shipIconCache';
|
import {
|
||||||
|
getShipIconSpec,
|
||||||
|
getTargetShipIconSpec,
|
||||||
|
getShipIconAngle,
|
||||||
|
SPEED_THRESHOLD_KN,
|
||||||
|
} from '../../../shared/lib/map/shipKind';
|
||||||
|
|
||||||
|
/** 64×64 white ring SVG — mask:true로 getColor 틴트 적용 */
|
||||||
|
const ALARM_RING_ICON_URL = `data:image/svg+xml,${encodeURIComponent(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="27" fill="none" stroke="white" stroke-width="5"/></svg>',
|
||||||
|
)}`;
|
||||||
|
|
||||||
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
|
/* ── 공통 콜백 인터페이스 ─────────────────────────────── */
|
||||||
|
|
||||||
@ -64,7 +69,6 @@ interface DeckSelectCallbacks {
|
|||||||
|
|
||||||
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
|
export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelectCallbacks {
|
||||||
shipLayerData: AisTarget[];
|
shipLayerData: AisTarget[];
|
||||||
shipOverlayLayerData: AisTarget[];
|
|
||||||
legacyTargetsOrdered: AisTarget[];
|
legacyTargetsOrdered: AisTarget[];
|
||||||
legacyOverlayTargets: AisTarget[];
|
legacyOverlayTargets: AisTarget[];
|
||||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
@ -101,10 +105,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
if (isTargetShip(t.mmsi)) shipTargetData.push(t);
|
||||||
else shipOtherData.push(t);
|
else shipOtherData.push(t);
|
||||||
}
|
}
|
||||||
const shipOverlayOtherData: AisTarget[] = [];
|
|
||||||
for (const t of ctx.shipOverlayLayerData) {
|
|
||||||
if (!isTargetShip(t.mmsi)) shipOverlayOtherData.push(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─ density ─ */
|
/* ─ density ─ */
|
||||||
if (ctx.showDensity) {
|
if (ctx.showDensity) {
|
||||||
@ -318,26 +318,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (shipOtherData.length > 0) {
|
if (shipOtherData.length > 0) {
|
||||||
layers.push(
|
|
||||||
new ScatterplotLayer<AisTarget>({
|
|
||||||
id: 'ships-other-halo',
|
|
||||||
data: shipOtherData,
|
|
||||||
pickable: false,
|
|
||||||
billboard: false,
|
|
||||||
parameters: overlayParams,
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
|
||||||
radiusUnits: 'pixels',
|
|
||||||
getRadius: 10,
|
|
||||||
getFillColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET).slice(0, 3).concat(40) as unknown as [number, number, number, number],
|
|
||||||
getLineColor: (d) => {
|
|
||||||
const c = getShipColor(d, null, null, EMPTY_MMSI_SET);
|
|
||||||
return [c[0], c[1], c[2], 100] as [number, number, number, number];
|
|
||||||
},
|
|
||||||
stroked: true,
|
|
||||||
lineWidthUnits: 'pixels',
|
|
||||||
getLineWidth: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
layers.push(
|
layers.push(
|
||||||
new IconLayer<AisTarget>({
|
new IconLayer<AisTarget>({
|
||||||
id: 'ships-other',
|
id: 'ships-other',
|
||||||
@ -345,14 +325,11 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
iconAtlas: getCachedShipIcon(),
|
getIcon: (d) => getShipIconSpec(d.signalKindCode ?? d.shipKindCode, d.sog),
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
|
||||||
getIcon: () => 'ship',
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
getAngle: (d) => getShipIconAngle(d.signalKindCode ?? d.shipKindCode, d.cog),
|
||||||
sizeUnits: 'pixels',
|
sizeUnits: 'pixels',
|
||||||
getSize: () => FLAT_SHIP_ICON_SIZE,
|
getSize: FLAT_OTHER_SHIP_SIZE,
|
||||||
getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET),
|
|
||||||
onHover: shipOnHover,
|
onHover: shipOnHover,
|
||||||
onClick: shipOnClick,
|
onClick: shipOnClick,
|
||||||
alphaCutoff: 0.05,
|
alphaCutoff: 0.05,
|
||||||
@ -360,31 +337,6 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shipOverlayOtherData.length > 0) {
|
|
||||||
layers.push(
|
|
||||||
new IconLayer<AisTarget>({
|
|
||||||
id: 'ships-overlay-other',
|
|
||||||
data: shipOverlayOtherData,
|
|
||||||
pickable: false,
|
|
||||||
billboard: false,
|
|
||||||
parameters: overlayParams,
|
|
||||||
iconAtlas: getCachedShipIcon(),
|
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
|
||||||
getIcon: () => 'ship',
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
|
||||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
|
||||||
sizeUnits: 'pixels',
|
|
||||||
getSize: (d) => {
|
|
||||||
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED;
|
|
||||||
if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED;
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
getColor: (d) => getShipColor(d, ctx.selectedMmsi, null, ctx.shipHighlightSet),
|
|
||||||
alphaCutoff: 0.05,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.legacyTargetsOrdered.length > 0) {
|
if (ctx.legacyTargetsOrdered.length > 0) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new ScatterplotLayer<AisTarget>({
|
new ScatterplotLayer<AisTarget>({
|
||||||
@ -413,14 +365,14 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
iconAtlas: getCachedShipIcon(),
|
getIcon: (d) => getTargetShipIconSpec(ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, d.sog),
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
|
||||||
getIcon: () => 'ship',
|
|
||||||
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }),
|
getAngle: (d) => {
|
||||||
|
const isMoving = Number.isFinite(d.sog) && (d.sog as number) > SPEED_THRESHOLD_KN;
|
||||||
|
return isMoving ? -getDisplayHeading({ cog: d.cog, heading: d.heading }) : 0;
|
||||||
|
},
|
||||||
sizeUnits: 'pixels',
|
sizeUnits: 'pixels',
|
||||||
getSize: () => FLAT_SHIP_ICON_SIZE,
|
getSize: FLAT_TARGET_SHIP_SIZE,
|
||||||
getColor: (d) => getShipColor(d, null, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET),
|
|
||||||
onHover: shipOnHover,
|
onHover: shipOnHover,
|
||||||
onClick: shipOnClick,
|
onClick: shipOnClick,
|
||||||
alphaCutoff: 0.05,
|
alphaCutoff: 0.05,
|
||||||
@ -444,14 +396,26 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ legacy overlay (highlight/selected) ─ */
|
/* ─ legacy overlay (highlight/selected — breathing ring, no icon enlargement) ─ */
|
||||||
if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) {
|
if (ctx.showShips && ctx.legacyOverlayTargets.length > 0) {
|
||||||
layers.push(new ScatterplotLayer<AisTarget>({ id: 'legacy-halo-overlay', data: ctx.legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'pixels', getRadius: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return HALO_OUTLINE_COLOR_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return HALO_OUTLINE_COLOR_HIGHLIGHTED; return HALO_OUTLINE_COLOR; }, getPosition: (d) => [d.lon, d.lat] as [number, number] }));
|
layers.push(new ScatterplotLayer<AisTarget>({
|
||||||
}
|
id: 'legacy-halo-overlay',
|
||||||
|
data: ctx.legacyOverlayTargets,
|
||||||
if (ctx.showShips && ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi)).length > 0) {
|
pickable: false,
|
||||||
const shipOverlayTargetData2 = ctx.shipOverlayLayerData.filter((t) => ctx.legacyHits?.has(t.mmsi));
|
billboard: false,
|
||||||
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } }));
|
parameters: overlayParams,
|
||||||
|
filled: false,
|
||||||
|
stroked: true,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: (d) => (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi ? 18 : 16),
|
||||||
|
lineWidthUnits: 'pixels',
|
||||||
|
getLineWidth: 2.5,
|
||||||
|
getLineColor: (d) => {
|
||||||
|
if (ctx.selectedMmsi && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 200];
|
||||||
|
return [245, 158, 11, 190];
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ ship name labels (Mercator) ─ */
|
/* ─ ship name labels (Mercator) ─ */
|
||||||
@ -496,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(
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Ship SVG 아이콘을 미리 fetch하여 data URL로 캐시.
|
|
||||||
* Deck.gl IconLayer가 매번 iconAtlas URL을 fetch하지 않도록
|
|
||||||
* 인라인 data URL을 전달한다.
|
|
||||||
*/
|
|
||||||
const SHIP_SVG_URL = '/assets/ship.svg';
|
|
||||||
|
|
||||||
let _cachedDataUrl: string | null = null;
|
|
||||||
let _promise: Promise<string> | null = null;
|
|
||||||
|
|
||||||
function preloadShipIcon(): Promise<string> {
|
|
||||||
if (_cachedDataUrl) return Promise.resolve(_cachedDataUrl);
|
|
||||||
if (_promise) return _promise;
|
|
||||||
_promise = fetch(SHIP_SVG_URL)
|
|
||||||
.then((res) => res.text())
|
|
||||||
.then((svg) => {
|
|
||||||
_cachedDataUrl = `data:image/svg+xml;base64,${btoa(svg)}`;
|
|
||||||
return _cachedDataUrl;
|
|
||||||
})
|
|
||||||
.catch(() => SHIP_SVG_URL);
|
|
||||||
return _promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 캐시된 data URL 또는 폴백 URL 반환 */
|
|
||||||
export function getCachedShipIcon(): string {
|
|
||||||
return _cachedDataUrl ?? SHIP_SVG_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모듈 임포트 시 즉시 로드 시작
|
|
||||||
preloadShipIcon();
|
|
||||||
@ -1,12 +1,10 @@
|
|||||||
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 { rgbToHex } from '../../../shared/lib/map/palette';
|
import { rgbToHex } from '../../../shared/lib/map/palette';
|
||||||
|
import { SHIP_KIND_COLORS } from '../../../shared/lib/map/shipKind';
|
||||||
import {
|
import {
|
||||||
ANCHOR_SPEED_THRESHOLD_KN,
|
ANCHOR_SPEED_THRESHOLD_KN,
|
||||||
LEGACY_CODE_COLORS,
|
LEGACY_CODE_COLORS,
|
||||||
MAP_SELECTED_SHIP_RGB,
|
|
||||||
MAP_HIGHLIGHT_SHIP_RGB,
|
|
||||||
MAP_DEFAULT_SHIP_RGB,
|
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { isFiniteNumber } from './setUtils';
|
import { isFiniteNumber } from './setUtils';
|
||||||
import { normalizeAngleDeg } from './geometry';
|
import { normalizeAngleDeg } from './geometry';
|
||||||
@ -53,44 +51,21 @@ export function lightenColor(rgb: [number, number, number], ratio = 0.32) {
|
|||||||
|
|
||||||
export function getGlobeBaseShipColor({
|
export function getGlobeBaseShipColor({
|
||||||
legacy,
|
legacy,
|
||||||
sog,
|
signalKindCode,
|
||||||
}: {
|
}: {
|
||||||
legacy: string | null;
|
legacy: string | null;
|
||||||
sog: number | null;
|
signalKindCode?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// 대상 선박: legacy code 색상 (밝게)
|
||||||
if (legacy) {
|
if (legacy) {
|
||||||
const rgb = LEGACY_CODE_COLORS[legacy];
|
const rgb = LEGACY_CODE_COLORS[legacy];
|
||||||
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
if (rgb) return rgbToHex(lightenColor(rgb, 0.38));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep alpha control in icon-opacity only to avoid double-multiplying transparency.
|
// 기타 AIS: signalKindCode → 선종별 색상
|
||||||
if (!isFiniteNumber(sog)) return '#64748b';
|
const kindColor = SHIP_KIND_COLORS[signalKindCode || '000027'];
|
||||||
if (sog >= 10) return '#94a3b8';
|
if (kindColor) return kindColor;
|
||||||
if (sog >= 1) return '#64748b';
|
return '#607D8B';
|
||||||
return '#475569';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShipColor(
|
|
||||||
t: AisTarget,
|
|
||||||
selectedMmsi: number | null,
|
|
||||||
legacyShipCode: string | null,
|
|
||||||
highlightedMmsis: Set<number>,
|
|
||||||
): [number, number, number, number] {
|
|
||||||
if (selectedMmsi && t.mmsi === selectedMmsi) {
|
|
||||||
return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255];
|
|
||||||
}
|
|
||||||
if (highlightedMmsis.has(t.mmsi)) {
|
|
||||||
return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235];
|
|
||||||
}
|
|
||||||
if (legacyShipCode) {
|
|
||||||
const rgb = LEGACY_CODE_COLORS[legacyShipCode];
|
|
||||||
if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
|
|
||||||
return [245, 158, 11, 235];
|
|
||||||
}
|
|
||||||
if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175];
|
|
||||||
if (t.sog >= 10) return [148, 163, 184, 215];
|
|
||||||
if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 210];
|
|
||||||
return [71, 85, 105, 200];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGlobeShipFeature(
|
export function buildGlobeShipFeature(
|
||||||
@ -108,7 +83,10 @@ export function buildGlobeShipFeature(
|
|||||||
mmsi: t.mmsi,
|
mmsi: t.mmsi,
|
||||||
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
heading: getDisplayHeading({ cog: t.cog, heading: t.heading, offset }),
|
||||||
anchored,
|
anchored,
|
||||||
color: getGlobeBaseShipColor({ legacy: legacy?.shipCode ?? null, sog: t.sog ?? null }),
|
color: getGlobeBaseShipColor({
|
||||||
|
legacy: legacy?.shipCode ?? null,
|
||||||
|
signalKindCode: t.signalKindCode || t.shipKindCode || '000027',
|
||||||
|
}),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
highlighted: isHighlighted,
|
highlighted: isHighlighted,
|
||||||
permitted: legacy ? 1 : 0,
|
permitted: legacy ? 1 : 0,
|
||||||
|
|||||||
@ -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,23 +147,31 @@ export function GlobalTrackReplayPanel() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
onPointerDown={handleHeaderPointerDown}
|
<strong style={{ fontSize: 13 }}>
|
||||||
|
Track Replay{vesselCount > 0 ? ` (${vesselCount}척)` : ''}
|
||||||
|
</strong>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{onToggleVesselList && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleVesselList}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
fontSize: 11,
|
||||||
alignItems: 'center',
|
padding: '4px 8px',
|
||||||
justifyContent: 'space-between',
|
borderRadius: 6,
|
||||||
marginBottom: 8,
|
border: `1px solid ${isVesselListOpen ? 'rgba(96,165,250,0.7)' : 'rgba(96,165,250,0.35)'}`,
|
||||||
cursor: isDragging ? 'grabbing' : 'grab',
|
background: isVesselListOpen ? 'rgba(37,99,235,0.35)' : 'rgba(37,99,235,0.12)',
|
||||||
userSelect: 'none',
|
color: isVesselListOpen ? '#bfdbfe' : '#93c5fd',
|
||||||
touchAction: 'none',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{ fontSize: 13 }}>Track Replay</strong>
|
선박 목록
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => closeTrackQuery()}
|
onClick={() => closeTrackQuery()}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
@ -247,73 +185,41 @@ export function GlobalTrackReplayPanel() {
|
|||||||
닫기
|
닫기
|
||||||
</button>
|
</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