Compare commits

..

8 커밋

작성자 SHA1 메시지 날짜
de0d74ce3f Merge pull request 'chore: 기타AIS 토글 기본값 on으로 변경' (#36) from feature/ship-image-thumbnails into develop
Reviewed-on: #36
2026-02-20 04:07:01 +09:00
93db39e0d5 chore: 기타AIS 토글 기본값 on으로 변경 2026-02-20 04:05:01 +09:00
03337fc99c Merge pull request 'feat(ship-image): 선박 이미지 썸네일 및 갤러리 기능' (#34) from feature/ship-image-thumbnails into develop
Reviewed-on: #34
2026-02-20 04:02:25 +09:00
d66d0f0c89 fix(map): Globe 사진 인디케이터 오프셋 제거
- circle-translate 제거하여 선박 아이콘과 동일 중심 위치에 표시
2026-02-20 04:00:45 +09:00
d5a8be3b96 fix(map): Globe 사진 인디케이터 네이티브 레이어 전환
- Globe Deck.gl ScatterplotLayer 아티팩트(파란 막대) 수정
- MapLibre 네이티브 circle 레이어로 사진 인디케이터 구현
2026-02-20 03:57:45 +09:00
e72e2f14f6 feat(ship-image): 선박 이미지 썸네일 및 갤러리 기능
- AIS 타겟에 shipImagePath/shipImageCount 필드 추가
- 선박 이미지 API 연동 (fetchShipImagesByImo)
- 지도 위 사진 인디케이터 (ScatterplotLayer)
- 호버 툴팁에 썸네일 표시
- 정보 패널 카드 갤러리 (스크롤+화살표)
- 고화질 이미지 모달 (initialIndex 지원)
- Vite 프록시 /shipimg 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 03:45:25 +09:00
71f235097c feat(api): signal-batch v2 API 마이그레이션 (#32) 2026-02-20 00:33:08 +09:00
1e742e5a3d Merge pull request 'fix(hook): commit-msg 정규식 통일 (template-common v1.2.0)' (#31) from fix/sync-commit-msg into develop 2026-02-18 21:17:32 +09:00
30개의 변경된 파일1109개의 추가작업 그리고 466개의 파일을 삭제

파일 보기

@ -23,160 +23,6 @@ app.setErrorHandler((err, req, reply) => {
app.get("/health", async () => ({ ok: true })); app.get("/health", async () => ({ ok: true }));
const AIS_UPSTREAM_BASE = "http://211.208.115.83:8041";
const AIS_UPSTREAM_PATH = "/snp-api/api/ais-target/search";
app.get<{
Querystring: {
minutes?: string;
bbox?: string;
};
}>("/api/ais-target/search", async (req, reply) => {
const minutesRaw = req.query.minutes ?? "60";
const minutes = Number(minutesRaw);
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 60 * 24) {
return reply.code(400).send({ success: false, message: "invalid minutes", data: [], errorCode: "BAD_REQUEST" });
}
const bboxRaw = req.query.bbox;
const bbox = parseBbox(bboxRaw);
if (bboxRaw && !bbox) {
return reply.code(400).send({ success: false, message: "invalid bbox", data: [], errorCode: "BAD_REQUEST" });
}
const u = new URL(AIS_UPSTREAM_PATH, AIS_UPSTREAM_BASE);
u.searchParams.set("minutes", String(minutes));
const controller = new AbortController();
const timeoutMs = 20_000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } });
const txt = await res.text();
if (!res.ok) {
req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "AIS upstream error");
return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" });
}
// Apply optional bbox filtering server-side to reduce payload to the browser.
let json: { data?: unknown; message?: string };
try {
json = JSON.parse(txt) as { data?: unknown; message?: string };
} catch (e) {
req.log.warn({ err: e, body: txt.slice(0, 2000) }, "AIS upstream returned invalid JSON");
return reply
.code(502)
.send({ success: false, message: "upstream invalid json", data: [], errorCode: "UPSTREAM_INVALID_JSON" });
}
if (!json || typeof json !== "object") {
req.log.warn({ body: txt.slice(0, 2000) }, "AIS upstream returned non-object JSON");
return reply
.code(502)
.send({ success: false, message: "upstream invalid payload", data: [], errorCode: "UPSTREAM_INVALID_PAYLOAD" });
}
const rows = Array.isArray(json.data) ? (json.data as unknown[]) : [];
const filtered = bbox
? rows.filter((r) => {
if (!r || typeof r !== "object") return false;
const lat = (r as { lat?: unknown }).lat;
const lon = (r as { lon?: unknown }).lon;
if (typeof lat !== "number" || typeof lon !== "number") return false;
return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax;
})
: rows;
if (bbox) {
json.message = `${json.message ?? ""} (bbox: ${filtered.length}/${rows.length})`.trim();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(json as any).data = filtered;
reply.type("application/json").send(json);
} catch (e) {
const name = e instanceof Error ? e.name : "";
const isTimeout = name === "AbortError";
req.log.warn({ err: e, url: u.toString() }, "AIS proxy request failed");
return reply.code(isTimeout ? 504 : 502).send({
success: false,
message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed",
data: [],
errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED",
});
} finally {
clearTimeout(timeout);
}
});
function parseBbox(raw: string | undefined) {
if (!raw) return null;
const parts = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length !== 4) return null;
const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p));
const ok =
Number.isFinite(lonMin) &&
Number.isFinite(latMin) &&
Number.isFinite(lonMax) &&
Number.isFinite(latMax) &&
lonMin >= -180 &&
lonMax <= 180 &&
latMin >= -90 &&
latMax <= 90 &&
lonMin < lonMax &&
latMin < latMax;
if (!ok) return null;
return { lonMin, latMin, lonMax, latMax };
}
app.get<{
Params: { mmsi: string };
Querystring: { minutes?: string };
}>("/api/ais-target/:mmsi/track", async (req, reply) => {
const mmsiRaw = req.params.mmsi;
const mmsi = Number(mmsiRaw);
if (!Number.isFinite(mmsi) || mmsi <= 0 || !Number.isInteger(mmsi)) {
return reply.code(400).send({ success: false, message: "invalid mmsi", data: [], errorCode: "BAD_REQUEST" });
}
const minutesRaw = req.query.minutes ?? "360";
const minutes = Number(minutesRaw);
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 7200) {
return reply.code(400).send({ success: false, message: "invalid minutes (1-7200)", data: [], errorCode: "BAD_REQUEST" });
}
const u = new URL(`/snp-api/api/ais-target/${mmsi}/track`, AIS_UPSTREAM_BASE);
u.searchParams.set("minutes", String(minutes));
const controller = new AbortController();
const timeoutMs = 20_000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } });
const txt = await res.text();
if (!res.ok) {
req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "Track upstream error");
return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" });
}
reply.type("application/json").send(txt);
} catch (e) {
const name = e instanceof Error ? e.name : "";
const isTimeout = name === "AbortError";
req.log.warn({ err: e, url: u.toString() }, "Track proxy request failed");
return reply.code(isTimeout ? 504 : 502).send({
success: false,
message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed",
data: [],
errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED",
});
} finally {
clearTimeout(timeout);
}
});
app.get("/zones", async (_req, reply) => { app.get("/zones", async (_req, reply) => {
const zonesPath = path.resolve( const zonesPath = path.resolve(
process.cwd(), process.cwd(),

파일 보기

@ -65,6 +65,209 @@
font-weight: 600; font-weight: 600;
} }
/* ── Ship image gallery ── */
.ship-image-gallery-wrap {
position: relative;
margin: 4px 0 6px;
}
.ship-image-gallery {
display: flex;
gap: 6px;
overflow-x: auto;
padding: 4px 0;
scrollbar-width: thin;
scroll-snap-type: x proximity;
scroll-behavior: smooth;
}
.ship-image-gallery__thumb {
flex-shrink: 0;
width: 76px;
height: 56px;
padding: 0;
border: 2px solid transparent;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
background: var(--wing-subtle);
transition: border-color 0.15s;
scroll-snap-align: start;
}
.ship-image-gallery__thumb:hover {
border-color: var(--accent);
}
.ship-image-gallery__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ship-image-gallery__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 22px;
height: 32px;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
padding: 0;
}
.ship-image-gallery__arrow:hover {
background: rgba(0, 0, 0, 0.85);
}
.ship-image-gallery__arrow--left { left: 0; }
.ship-image-gallery__arrow--right { right: 0; }
/* ── Ship image modal ── */
.ship-image-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
}
.ship-image-modal__content {
position: relative;
max-width: min(92vw, 900px);
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--wing-glass-dense);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.ship-image-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--wing-subtle);
}
.ship-image-modal__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
}
.ship-image-modal__counter {
font-size: 11px;
color: var(--muted);
}
.ship-image-modal__close {
background: none;
border: none;
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
}
.ship-image-modal__close:hover {
color: var(--text);
}
.ship-image-modal__body {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 0;
padding: 8px;
}
.ship-image-modal__img-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.ship-image-modal__img {
max-width: 100%;
max-height: 72vh;
border-radius: 6px;
object-fit: contain;
transition: opacity 0.2s;
}
.ship-image-modal__spinner {
position: absolute;
width: 32px;
height: 32px;
border: 3px solid rgba(148, 163, 184, 0.28);
border-top-color: var(--accent);
border-radius: 50%;
animation: map-loader-spin 0.7s linear infinite;
}
.ship-image-modal__error {
font-size: 12px;
color: var(--muted);
}
.ship-image-modal__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 28px;
width: 36px;
height: 48px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
z-index: 2;
}
.ship-image-modal__nav:hover {
background: rgba(0, 0, 0, 0.7);
}
.ship-image-modal__nav--prev { left: 8px; }
.ship-image-modal__nav--next { right: 8px; }
.ship-image-modal__footer {
display: flex;
gap: 12px;
padding: 8px 14px;
font-size: 10px;
color: var(--muted);
border-top: 1px solid var(--wing-subtle);
}
.map-loader-overlay { .map-loader-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;

파일 보기

@ -0,0 +1,39 @@
/** GET /api/v2/vessels/chnprmship/recent-positions 응답 항목 */
export interface ChnPrmShipPositionDto {
mmsi: string;
imo: number;
name: string;
callsign: string;
vesselType: string;
lat: number;
lon: number;
sog: number;
cog: number;
heading: number;
length: number;
width: number;
draught: number;
destination: string;
status: string;
signalKindCode: string;
messageTimestamp: string;
shipImagePath?: string | null;
shipImageCount?: number;
}
/** GET /api/v2/vessels/recent-positions 응답 항목 */
export interface RecentVesselPositionDto {
mmsi: string;
imo?: number;
lon: number;
lat: number;
sog: number;
cog: number;
shipNm: string;
shipTy: string;
shipKindCode: string;
nationalCode: string;
lastUpdate: string;
shipImagePath?: string | null;
shipImageCount?: number;
}

파일 보기

@ -0,0 +1,109 @@
import type { AisTarget } from '../model/types';
import type { ChnPrmShipPositionDto, RecentVesselPositionDto } from './dto';
const SIGNAL_BATCH_BASE = '/signal-batch';
/* ── 내부 어댑터 ── */
function adaptChnPrmShip(dto: ChnPrmShipPositionDto): AisTarget {
return {
mmsi: Number(dto.mmsi),
imo: dto.imo ?? 0,
name: dto.name ?? '',
callsign: dto.callsign ?? '',
vesselType: dto.vesselType ?? '',
lat: dto.lat,
lon: dto.lon,
heading: dto.heading ?? 0,
sog: dto.sog ?? 0,
cog: dto.cog ?? 0,
rot: 0,
length: dto.length ?? 0,
width: dto.width ?? 0,
draught: dto.draught ?? 0,
destination: dto.destination ?? '',
eta: '',
status: dto.status ?? '',
messageTimestamp: dto.messageTimestamp ?? '',
receivedDate: '',
source: 'chnprmship',
classType: '',
signalKindCode: dto.signalKindCode,
shipImagePath: dto.shipImagePath ?? null,
shipImageCount: dto.shipImageCount ?? 0,
};
}
function adaptRecentVessel(dto: RecentVesselPositionDto): AisTarget {
return {
mmsi: Number(dto.mmsi),
imo: dto.imo ?? 0,
name: dto.shipNm ?? '',
callsign: '',
vesselType: dto.shipTy ?? '',
lat: dto.lat,
lon: dto.lon,
heading: 0,
sog: dto.sog ?? 0,
cog: dto.cog ?? 0,
rot: 0,
length: 0,
width: 0,
draught: 0,
destination: '',
eta: '',
status: '',
messageTimestamp: dto.lastUpdate ?? '',
receivedDate: '',
source: 'recent',
classType: '',
shipKindCode: dto.shipKindCode,
nationalCode: dto.nationalCode,
shipImagePath: dto.shipImagePath ?? null,
shipImageCount: dto.shipImageCount ?? 0,
};
}
/* ── 공개 API ── */
/** 허가선박 최근 위치 → AisTarget[] */
export async function fetchChnPrmShipPositions(
params: { minutes: number },
signal?: AbortSignal,
): Promise<AisTarget[]> {
const u = new URL(
`${SIGNAL_BATCH_BASE}/api/v2/vessels/chnprmship/recent-positions`,
window.location.origin,
);
u.searchParams.set('minutes', String(params.minutes));
const res = await fetch(u, { signal, headers: { accept: 'application/json' } });
if (!res.ok) {
const txt = await res.text().catch(() => '');
throw new Error(`ChnPrmShip API ${res.status}: ${txt.slice(0, 200)}`);
}
const json: unknown = await res.json();
const dtos: ChnPrmShipPositionDto[] = Array.isArray(json) ? json : [];
return dtos.map(adaptChnPrmShip);
}
/** 전체 선박 최근 위치 → AisTarget[] */
export async function fetchRecentPositions(
params: { minutes: number },
signal?: AbortSignal,
): Promise<AisTarget[]> {
const u = new URL(
`${SIGNAL_BATCH_BASE}/api/v2/vessels/recent-positions`,
window.location.origin,
);
u.searchParams.set('minutes', String(params.minutes));
const res = await fetch(u, { signal, headers: { accept: 'application/json' } });
if (!res.ok) {
const txt = await res.text().catch(() => '');
throw new Error(`RecentPositions API ${res.status}: ${txt.slice(0, 200)}`);
}
const json: unknown = await res.json();
const dtos: RecentVesselPositionDto[] = Array.isArray(json) ? json : [];
return dtos.map(adaptRecentVessel);
}

파일 보기

@ -1,50 +0,0 @@
import type { AisTargetSearchResponse } from "../model/types";
export type SearchAisTargetsParams = {
minutes: number;
bbox?: string;
centerLon?: number;
centerLat?: number;
radiusMeters?: number;
};
export async function searchAisTargets(params: SearchAisTargetsParams, signal?: AbortSignal) {
// Same convention as the "dark" project:
// - dev: default to Vite proxy base `/snp-api`
// - prod/other: can be overridden via `VITE_API_URL` (e.g. `http://host:8041/snp-api`)
const base = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
const u = new URL(`${base}/api/ais-target/search`, window.location.origin);
u.searchParams.set("minutes", String(params.minutes));
if (params.bbox) u.searchParams.set("bbox", params.bbox);
if (typeof params.centerLon === "number" && Number.isFinite(params.centerLon)) {
u.searchParams.set("centerLon", String(params.centerLon));
}
if (typeof params.centerLat === "number" && Number.isFinite(params.centerLat)) {
u.searchParams.set("centerLat", String(params.centerLat));
}
if (typeof params.radiusMeters === "number" && Number.isFinite(params.radiusMeters)) {
u.searchParams.set("radiusMeters", String(params.radiusMeters));
}
const res = await fetch(u, { signal, headers: { accept: "application/json" } });
const txt = await res.text();
let json: unknown = null;
try {
json = JSON.parse(txt);
} catch {
// ignore
}
if (!res.ok) {
const msg =
json && typeof json === "object" && typeof (json as { message?: unknown }).message === "string"
? (json as { message: string }).message
: txt.slice(0, 200) || res.statusText;
throw new Error(`AIS target API failed: ${res.status} ${msg}`);
}
if (!json || typeof json !== "object") throw new Error("AIS target API returned invalid payload");
const parsed = json as AisTargetSearchResponse;
if (!parsed.success) throw new Error(parsed.message || "AIS target API returned success=false");
return parsed;
}

파일 보기

@ -1,32 +0,0 @@
import type { AisTargetSearchResponse } from '../model/types';
export async function searchChnprmship(
params: { minutes: number },
signal?: AbortSignal,
): Promise<AisTargetSearchResponse> {
const base = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
const u = new URL(`${base}/api/ais-target/chnprmship`, window.location.origin);
u.searchParams.set('minutes', String(params.minutes));
const res = await fetch(u, { signal, headers: { accept: 'application/json' } });
const txt = await res.text();
let json: unknown = null;
try {
json = JSON.parse(txt);
} catch {
// ignore
}
if (!res.ok) {
const msg =
json && typeof json === 'object' && typeof (json as { message?: unknown }).message === 'string'
? (json as { message: string }).message
: txt.slice(0, 200) || res.statusText;
throw new Error(`chnprmship API failed: ${res.status} ${msg}`);
}
if (!json || typeof json !== 'object') throw new Error('chnprmship API returned invalid payload');
const parsed = json as AisTargetSearchResponse;
if (!parsed.success) throw new Error(parsed.message || 'chnprmship API returned success=false');
return parsed;
}

파일 보기

@ -20,12 +20,10 @@ export type AisTarget = {
receivedDate: string; receivedDate: string;
source: string; source: string;
classType: string; classType: string;
}; signalKindCode?: string;
shipKindCode?: string;
export type AisTargetSearchResponse = { nationalCode?: string;
success: boolean; shipImagePath?: string | null;
message: string; shipImageCount?: number;
data: AisTarget[];
errorCode: string | null;
}; };

파일 보기

@ -0,0 +1,35 @@
import type { ShipImageInfo } from '../model/types';
const BASE = '/signal-batch';
export async function fetchShipImagesByImo(
imo: number,
signal?: AbortSignal,
): Promise<ShipImageInfo[]> {
const res = await fetch(`${BASE}/api/v2/shipimg/${imo}`, {
signal,
headers: { accept: 'application/json' },
});
if (!res.ok) return [];
const json: unknown = await res.json();
return Array.isArray(json) ? json : [];
}
/** 확장자가 없으면 suffix를 붙여서 정규화 */
const ensureJpg = (path: string, suffix: '_1.jpg' | '_2.jpg'): string => {
if (/\.jpe?g$/i.test(path)) return path;
return `${path}${suffix}`;
};
/** path → 썸네일 URL (_1.jpg) */
export const toThumbnailUrl = (path: string): string => {
const normalized = ensureJpg(path, '_1.jpg');
return normalized.startsWith('http') || normalized.startsWith('/') ? normalized : `/shipimg/${normalized}`;
};
/** path → 고화질 URL (_2.jpg) */
export const toHighResUrl = (path: string): string => {
const withExt = ensureJpg(path, '_2.jpg');
const resolved = withExt.startsWith('http') || withExt.startsWith('/') ? withExt : `/shipimg/${withExt}`;
return resolved.replace(/_1\.jpg$/i, '_2.jpg');
};

파일 보기

@ -0,0 +1,7 @@
/** 선박 이미지 메타데이터 — /signal-batch/api/v2/shipimg/{imo} 응답 */
export interface ShipImageInfo {
picId: number;
path: string;
copyright: string;
date: string;
}

파일 보기

@ -1,32 +0,0 @@
import type { TrackResponse } from '../model/types';
const API_BASE = (import.meta.env.VITE_API_URL || '/snp-api').replace(/\/$/, '');
export async function fetchVesselTrack(
mmsi: number,
minutes: number,
signal?: AbortSignal,
): Promise<TrackResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
const combinedSignal = signal ?? controller.signal;
try {
const url = `${API_BASE}/api/ais-target/${mmsi}/track?minutes=${minutes}`;
const res = await fetch(url, {
signal: combinedSignal,
headers: { accept: 'application/json' },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Track API error ${res.status}: ${text.slice(0, 200)}`);
}
const json = (await res.json()) as TrackResponse;
return json;
} finally {
clearTimeout(timeout);
}
}

파일 보기

@ -1,9 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from 'react';
import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets"; import { fetchChnPrmShipPositions, fetchRecentPositions } from '../../entities/aisTarget/api/fetchPositions';
import { searchChnprmship } from "../../entities/aisTarget/api/searchChnprmship"; import type { AisTarget } from '../../entities/aisTarget/model/types';
import type { AisTarget } from "../../entities/aisTarget/model/types";
export type AisPollingStatus = "idle" | "loading" | "ready" | "error"; export type AisPollingStatus = 'idle' | 'loading' | 'ready' | 'error';
export type AisPollingSnapshot = { export type AisPollingSnapshot = {
status: AisPollingStatus; status: AisPollingStatus;
@ -17,32 +16,31 @@ export type AisPollingSnapshot = {
lastDeleted: number; lastDeleted: number;
}; };
export type AisPollingOptions = { export interface AisPollingOptions {
/** 초기 chnprmship API 호출 시 minutes (기본 120) */ chnprmship?: {
chnprmshipMinutes?: number; initialMinutes?: number;
/** 주기적 폴링 시 search API minutes (기본 2) */ pollMinutes?: number;
incrementalMinutes?: number; intervalMs?: number;
/** 폴링 주기 ms (기본 60_000) */ retentionMinutes?: number;
intervalMs?: number; };
/** 보존 기간 (기본 chnprmshipMinutes) */ recent?: {
retentionMinutes?: number; initialMinutes?: number;
/** incremental 폴링 시 bbox 필터 */ pollMinutes?: number;
intervalMs?: number;
retentionMinutes?: number;
};
bbox?: string; bbox?: string;
/** incremental 폴링 시 중심 경도 */
centerLon?: number;
/** incremental 폴링 시 중심 위도 */
centerLat?: number;
/** incremental 폴링 시 반경(m) */
radiusMeters?: number;
enabled?: boolean; enabled?: boolean;
}; }
/* ── Store helpers ── */
function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) { function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
let inserted = 0; let inserted = 0;
let upserted = 0; let upserted = 0;
for (const r of rows) { for (const r of rows) {
if (!r || typeof r.mmsi !== "number") continue; if (!r || typeof r.mmsi !== 'number') continue;
const prev = store.get(r.mmsi); const prev = store.get(r.mmsi);
if (!prev) { if (!prev) {
@ -52,10 +50,17 @@ function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
continue; continue;
} }
// Keep newer rows only. If backend returns same/older timestamp, skip. // 소스 우선순위: chnprmship > recent
const prevTs = Date.parse(prev.messageTimestamp || ""); // recent 데이터는 기존 chnprmship 데이터를 절대 덮어쓰지 않음
const nextTs = Date.parse(r.messageTimestamp || ""); if (prev.source === 'chnprmship' && r.source === 'recent') continue;
if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue;
// 동일 소스: 더 최신 timestamp만 업데이트
const prevTs = Date.parse(prev.messageTimestamp || '');
const nextTs = Date.parse(r.messageTimestamp || '');
if (prev.source === r.source) {
if (Number.isFinite(prevTs) && Number.isFinite(nextTs) && nextTs <= prevTs) continue;
}
// 다른 소스 (recent→chnprmship): chnprmship이 항상 승리
store.set(r.mmsi, r); store.set(r.mmsi, r);
upserted += 1; upserted += 1;
@ -67,7 +72,7 @@ function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
function parseBbox(raw: string | undefined) { function parseBbox(raw: string | undefined) {
if (!raw) return null; if (!raw) return null;
const parts = raw const parts = raw
.split(",") .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
if (parts.length !== 4) return null; if (parts.length !== 4) return null;
@ -87,13 +92,21 @@ function parseBbox(raw: string | undefined) {
return { lonMin, latMin, lonMax, latMax }; return { lonMin, latMin, lonMax, latMax };
} }
function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bboxRaw: string | undefined) { function pruneStore(
const cutoffMs = Date.now() - retentionMinutes * 60_000; store: Map<number, AisTarget>,
chnprmRetention: number,
recentRetention: number,
bboxRaw: string | undefined,
) {
const now = Date.now();
const bbox = parseBbox(bboxRaw); const bbox = parseBbox(bboxRaw);
let deleted = 0; let deleted = 0;
for (const [mmsi, t] of store.entries()) { for (const [mmsi, t] of store.entries()) {
const ts = Date.parse(t.messageTimestamp || ""); const ts = Date.parse(t.messageTimestamp || '');
const retention = t.source === 'chnprmship' ? chnprmRetention : recentRetention;
const cutoffMs = now - retention * 60_000;
if (Number.isFinite(ts) && ts < cutoffMs) { if (Number.isFinite(ts) && ts < cutoffMs) {
store.delete(mmsi); store.delete(mmsi);
deleted += 1; deleted += 1;
@ -103,7 +116,7 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
if (bbox) { if (bbox) {
const lat = t.lat; const lat = t.lat;
const lon = t.lon; const lon = t.lon;
if (typeof lat !== "number" || typeof lon !== "number") { if (typeof lat !== 'number' || typeof lon !== 'number') {
store.delete(mmsi); store.delete(mmsi);
deleted += 1; deleted += 1;
continue; continue;
@ -119,23 +132,28 @@ function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bbo
return deleted; return deleted;
} }
/* ── Hook ── */
export function useAisTargetPolling(opts: AisPollingOptions = {}) { export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const chnprmshipMinutes = opts.chnprmshipMinutes ?? 120; const cp = opts.chnprmship ?? {};
const incrementalMinutes = opts.incrementalMinutes ?? 2; const rc = opts.recent ?? {};
const intervalMs = opts.intervalMs ?? 60_000; const cpInit = cp.initialMinutes ?? 120;
const retentionMinutes = opts.retentionMinutes ?? chnprmshipMinutes; const cpPoll = cp.pollMinutes ?? 2;
const enabled = opts.enabled ?? true; const cpInterval = cp.intervalMs ?? 60_000;
const cpRetention = cp.retentionMinutes ?? 120;
const rcInit = rc.initialMinutes ?? 15;
const rcPoll = rc.pollMinutes ?? 12;
const rcInterval = rc.intervalMs ?? 600_000;
const rcRetention = rc.retentionMinutes ?? 72;
const bbox = opts.bbox; const bbox = opts.bbox;
const centerLon = opts.centerLon; const enabled = opts.enabled ?? true;
const centerLat = opts.centerLat;
const radiusMeters = opts.radiusMeters;
const storeRef = useRef<Map<number, AisTarget>>(new Map()); const storeRef = useRef<Map<number, AisTarget>>(new Map());
const generationRef = useRef(0); const generationRef = useRef(0);
const [rev, setRev] = useState(0); const [rev, setRev] = useState(0);
const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({ const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({
status: "idle", status: 'idle',
error: null, error: null,
lastFetchAt: null, lastFetchAt: null,
lastFetchMinutes: null, lastFetchMinutes: null,
@ -153,19 +171,19 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const controller = new AbortController(); const controller = new AbortController();
const generation = ++generationRef.current; const generation = ++generationRef.current;
function applyResult(res: { data: AisTarget[]; message: string }, minutes: number) { function applyData(data: AisTarget[], minutes: number) {
if (cancelled || generation !== generationRef.current) return; if (cancelled || generation !== generationRef.current) return;
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data); const { inserted, upserted } = upsertByMmsi(storeRef.current, data);
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox); const deleted = pruneStore(storeRef.current, cpRetention, rcRetention, bbox);
const total = storeRef.current.size; const total = storeRef.current.size;
setSnapshot({ setSnapshot({
status: "ready", status: 'ready',
error: null, error: null,
lastFetchAt: new Date().toISOString(), lastFetchAt: new Date().toISOString(),
lastFetchMinutes: minutes, lastFetchMinutes: minutes,
lastMessage: res.message, lastMessage: null,
total, total,
lastUpserted: upserted, lastUpserted: upserted,
lastInserted: inserted, lastInserted: inserted,
@ -174,29 +192,10 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
setRev((r) => r + 1); setRev((r) => r + 1);
} }
async function runInitial(minutes: number) { async function pollChnprm(minutes: number) {
try { try {
setSnapshot((s) => ({ ...s, status: "loading", error: null })); const data = await fetchChnPrmShipPositions({ minutes }, controller.signal);
const res = await searchChnprmship({ minutes }, controller.signal); applyData(data, minutes);
applyResult(res, minutes);
} catch (e) {
if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({
...s,
status: "error",
error: e instanceof Error ? e.message : String(e),
}));
}
}
async function runIncremental(minutes: number) {
try {
setSnapshot((s) => ({ ...s, error: null }));
const res = await searchAisTargets(
{ minutes, bbox, centerLon, centerLat, radiusMeters },
controller.signal,
);
applyResult(res, minutes);
} catch (e) { } catch (e) {
if (cancelled || generation !== generationRef.current) return; if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({ setSnapshot((s) => ({
@ -206,10 +205,23 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
} }
} }
// Reset store when polling config changes. async function pollRecent(minutes: number) {
try {
const data = await fetchRecentPositions({ minutes }, controller.signal);
applyData(data, minutes);
} catch (e) {
if (cancelled || generation !== generationRef.current) return;
setSnapshot((s) => ({
...s,
error: e instanceof Error ? e.message : String(e),
}));
}
}
// 초기화: 스토어 리셋 + 두 API 병렬 호출
storeRef.current = new Map(); storeRef.current = new Map();
setSnapshot({ setSnapshot({
status: "loading", status: 'loading',
error: null, error: null,
lastFetchAt: null, lastFetchAt: null,
lastFetchMinutes: null, lastFetchMinutes: null,
@ -221,31 +233,21 @@ export function useAisTargetPolling(opts: AisPollingOptions = {}) {
}); });
setRev((r) => r + 1); setRev((r) => r + 1);
// 초기 로드: chnprmship API 1회 호출 void Promise.all([pollChnprm(cpInit), pollRecent(rcInit)]);
void runInitial(chnprmshipMinutes);
// 주기적 폴링: search API로 incremental 업데이트 // 이중 타이머: ChnPrmShip 1분, 전체 선박 10분
const id = window.setInterval(() => void runIncremental(incrementalMinutes), intervalMs); const cpTimer = window.setInterval(() => void pollChnprm(cpPoll), cpInterval);
const rcTimer = window.setInterval(() => void pollRecent(rcPoll), rcInterval);
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort(); controller.abort();
window.clearInterval(id); window.clearInterval(cpTimer);
window.clearInterval(rcTimer);
}; };
}, [ }, [cpInit, cpPoll, cpInterval, cpRetention, rcInit, rcPoll, rcInterval, rcRetention, bbox, enabled]);
chnprmshipMinutes,
incrementalMinutes,
intervalMs,
retentionMinutes,
bbox,
centerLon,
centerLat,
radiusMeters,
enabled,
]);
const targets = useMemo(() => { const targets = useMemo(() => {
// `rev` is a version counter so we recompute the array snapshot when the store changes.
void rev; void rev;
return Array.from(storeRef.current.values()); return Array.from(storeRef.current.values());
}, [rev]); }, [rev]);

파일 보기

@ -78,7 +78,7 @@ function makeLegacy(
* Group 1 ( ~1 NM, ) * Group 1 ( ~1 NM, )
* 위치: 서해남부(zone 3) 125.3°E 34.0°N * 위치: 서해남부(zone 3) 125.3°E 34.0°N
*/ */
const PT_01_AIS = makeAis({ mmsi: 990001, lat: 34.00, lon: 125.30, sog: 3.3, cog: 45, name: 'MOCK VESSEL 1' }); const PT_01_AIS = makeAis({ mmsi: 990001, lat: 34.00, lon: 125.30, sog: 3.3, cog: 45, name: 'MOCK VESSEL 1', shipImagePath: 'https://picsum.photos/seed/ship990001v1/800/600', shipImageCount: 3 });
const PT_02_AIS = makeAis({ mmsi: 990002, lat: 34.01, lon: 125.32, sog: 3.3, cog: 45, name: 'MOCK VESSEL 2' }); const PT_02_AIS = makeAis({ mmsi: 990002, lat: 34.01, lon: 125.32, sog: 3.3, cog: 45, name: 'MOCK VESSEL 2' });
const PT_01_LEG = makeLegacy({ const PT_01_LEG = makeLegacy({
@ -98,7 +98,7 @@ const PT_02_LEG = makeLegacy({
* Group 2 ( ~8 NM pair_separation alarm) * Group 2 ( ~8 NM pair_separation alarm)
* 위치: 서해남부(zone 3) 125.0°E 34.5°N * 위치: 서해남부(zone 3) 125.0°E 34.5°N
*/ */
const PT_03_AIS = makeAis({ mmsi: 990003, lat: 34.50, lon: 125.00, sog: 3.5, cog: 90, name: 'MOCK VESSEL 3' }); const PT_03_AIS = makeAis({ mmsi: 990003, lat: 34.50, lon: 125.00, sog: 3.5, cog: 90, name: 'MOCK VESSEL 3', shipImagePath: 'https://picsum.photos/seed/ship990003v1/800/600', shipImageCount: 2 });
const PT_04_AIS = makeAis({ mmsi: 990004, lat: 34.60, lon: 125.12, sog: 3.5, cog: 90, name: 'MOCK VESSEL 4' }); const PT_04_AIS = makeAis({ mmsi: 990004, lat: 34.60, lon: 125.12, sog: 3.5, cog: 90, name: 'MOCK VESSEL 4' });
const PT_03_LEG = makeLegacy({ const PT_03_LEG = makeLegacy({
@ -119,10 +119,10 @@ const PT_04_LEG = makeLegacy({
* 위치: 서해중간(zone 4) 124.8°E 35.2°N * 위치: 서해중간(zone 4) 124.8°E 35.2°N
* #11(GN) AIS 2 ais_stale alarm * #11(GN) AIS 2 ais_stale alarm
*/ */
const GN_01_AIS = makeAis({ mmsi: 990005, lat: 35.20, lon: 124.80, sog: 1.0, cog: 180, name: 'MOCK VESSEL 5' }); const GN_01_AIS = makeAis({ mmsi: 990005, lat: 35.20, lon: 124.80, sog: 1.0, cog: 180, name: 'MOCK VESSEL 5', shipImagePath: 'https://picsum.photos/seed/ship990005v1/800/600', shipImageCount: 1 });
const GN_02_AIS = makeAis({ mmsi: 990006, lat: 35.22, lon: 124.85, sog: 1.2, cog: 170, name: 'MOCK VESSEL 6' }); const GN_02_AIS = makeAis({ mmsi: 990006, lat: 35.22, lon: 124.85, sog: 1.2, cog: 170, name: 'MOCK VESSEL 6' });
const GN_03_AIS = makeAis({ mmsi: 990007, lat: 35.18, lon: 124.82, sog: 0.8, cog: 200, name: 'MOCK VESSEL 7' }); const GN_03_AIS = makeAis({ mmsi: 990007, lat: 35.18, lon: 124.82, sog: 0.8, cog: 200, name: 'MOCK VESSEL 7' });
const OT_01_AIS = makeAis({ mmsi: 990008, lat: 35.25, lon: 124.78, sog: 3.5, cog: 160, name: 'MOCK VESSEL 8' }); const OT_01_AIS = makeAis({ mmsi: 990008, lat: 35.25, lon: 124.78, sog: 3.5, cog: 160, name: 'MOCK VESSEL 8', shipImagePath: 'https://picsum.photos/seed/ship990008v1/800/600', shipImageCount: 4 });
const GN_04_AIS = makeAis({ const GN_04_AIS = makeAis({
mmsi: 990011, lat: 35.00, lon: 125.20, sog: 1.5, cog: 190, name: 'MOCK VESSEL 10', mmsi: 990011, lat: 35.00, lon: 125.20, sog: 1.5, cog: 190, name: 'MOCK VESSEL 10',
messageTimestamp: STALE_TS, receivedDate: STALE_TS, messageTimestamp: STALE_TS, receivedDate: STALE_TS,
@ -158,7 +158,7 @@ const GN_04_LEG = makeLegacy({
* Group 4 (FC PS ~0.15 NM transshipment alarm) * Group 4 (FC PS ~0.15 NM transshipment alarm)
* 위치: 서해남부(zone 3) 125.5°E 34.3°N * 위치: 서해남부(zone 3) 125.5°E 34.3°N
*/ */
const FC_01_AIS = makeAis({ mmsi: 990009, lat: 34.30, lon: 125.50, sog: 1.0, cog: 0, name: 'MOCK CARRIER 1' }); const FC_01_AIS = makeAis({ mmsi: 990009, lat: 34.30, lon: 125.50, sog: 1.0, cog: 0, name: 'MOCK CARRIER 1', shipImagePath: 'https://picsum.photos/seed/ship990009v1/800/600', shipImageCount: 2 });
const PS_01_AIS = makeAis({ mmsi: 990010, lat: 34.302, lon: 125.502, sog: 0.5, cog: 10, name: 'MOCK VESSEL 9' }); const PS_01_AIS = makeAis({ mmsi: 990010, lat: 34.302, lon: 125.502, sog: 0.5, cog: 10, name: 'MOCK VESSEL 9' });
const FC_01_LEG = makeLegacy({ const FC_01_LEG = makeLegacy({
@ -177,7 +177,7 @@ const PS_01_LEG = makeLegacy({
* PT는 zone 2,3 . zone 4() . * PT는 zone 2,3 . zone 4() .
* 위치: 서해중간(zone 4) 125.0°E 36.5°N * 위치: 서해중간(zone 4) 125.0°E 36.5°N
*/ */
const PT_05_AIS = makeAis({ mmsi: 990012, lat: 36.50, lon: 125.00, sog: 3.3, cog: 270, name: 'MOCK VESSEL 11' }); const PT_05_AIS = makeAis({ mmsi: 990012, lat: 36.50, lon: 125.00, sog: 3.3, cog: 270, name: 'MOCK VESSEL 11', shipImagePath: 'https://picsum.photos/seed/ship990012v1/800/600', shipImageCount: 1 });
const PT_05_LEG = makeLegacy({ const PT_05_LEG = makeLegacy({
permitNo: 'MOCK-P012', shipCode: 'PT', mmsiList: [990012], permitNo: 'MOCK-P012', shipCode: 'PT', mmsiList: [990012],

파일 보기

@ -9,6 +9,7 @@ export type MapToggleState = {
predictVectors: boolean; predictVectors: boolean;
shipLabels: boolean; shipLabels: boolean;
subcables: boolean; subcables: boolean;
shipPhotos: boolean;
}; };
type Props = { type Props = {
@ -26,6 +27,7 @@ export function MapToggles({ value, onToggle }: Props) {
{ id: "predictVectors", label: "예측 벡터" }, { id: "predictVectors", label: "예측 벡터" },
{ id: "shipLabels", label: "선박명 표시" }, { id: "shipLabels", label: "선박명 표시" },
{ id: "subcables", label: "해저케이블" }, { id: "subcables", label: "해저케이블" },
{ id: "shipPhotos", label: "선박 사진" },
]; ];
return ( return (

파일 보기

@ -0,0 +1,86 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { AisTarget } from '../../entities/aisTarget/model/types';
import type { ShipImageInfo } from '../../entities/shipImage/model/types';
import { fetchShipImagesByImo } from '../../entities/shipImage/api/fetchShipImages';
const BATCH_SIZE = 5;
/**
* (chnprmship) IMO별 .
* IMO , storeRef + rev .
*/
export function useShipImageMap(opts: {
targets: AisTarget[];
enabled?: boolean;
}): Map<number, ShipImageInfo[]> {
const { targets, enabled = true } = opts;
// IMO → images 캐시 (컴포넌트 수명 동안 유지)
const cacheRef = useRef<Map<number, ShipImageInfo[]>>(new Map());
// mmsi → images 결과 (렌더링용)
const storeRef = useRef<Map<number, ShipImageInfo[]>>(new Map());
const [rev, setRev] = useState(0);
// 고유 { mmsi, imo } 쌍 추출 (imo > 0만)
const entries = useMemo(() => {
const seen = new Set<number>();
const result: Array<{ mmsi: number; imo: number }> = [];
for (const t of targets) {
if (t.imo > 0 && !seen.has(t.mmsi)) {
seen.add(t.mmsi);
result.push({ mmsi: t.mmsi, imo: t.imo });
}
}
return result;
}, [targets]);
useEffect(() => {
if (!enabled || entries.length === 0) return;
const ac = new AbortController();
let cancelled = false;
(async () => {
// 캐시에 없는 IMO만 추출
const uncachedImos = new Set<number>();
for (const e of entries) {
if (!cacheRef.current.has(e.imo)) uncachedImos.add(e.imo);
}
// batch fetch (BATCH_SIZE 병렬)
const imoArr = Array.from(uncachedImos);
for (let i = 0; i < imoArr.length; i += BATCH_SIZE) {
if (cancelled) return;
const batch = imoArr.slice(i, i + BATCH_SIZE);
const results = await Promise.allSettled(
batch.map((imo) => fetchShipImagesByImo(imo, ac.signal)),
);
for (let j = 0; j < batch.length; j++) {
const r = results[j];
const images = r.status === 'fulfilled' ? r.value : [];
cacheRef.current.set(batch[j], images);
}
}
if (cancelled) return;
// mmsi → images 매핑 재구성
const next = new Map<number, ShipImageInfo[]>();
for (const e of entries) {
const imgs = cacheRef.current.get(e.imo);
if (imgs && imgs.length > 0) next.set(e.mmsi, imgs);
}
storeRef.current = next;
setRev((v) => v + 1);
})();
return () => {
cancelled = true;
ac.abort();
};
}, [entries, enabled]);
// rev를 의존성으로 두어 storeRef 갱신 시 새 참조 반환
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => storeRef.current, [rev]);
}

파일 보기

@ -1,5 +1,3 @@
import { fetchVesselTrack } from '../../../entities/vesselTrack/api/fetchTrack';
import { convertLegacyTrackPointsToProcessedTrack } from '../lib/adapters';
import type { ProcessedTrack } from '../model/track.types'; import type { ProcessedTrack } from '../model/track.types';
type QueryTrackByMmsiParams = { type QueryTrackByMmsiParams = {
@ -8,6 +6,7 @@ type QueryTrackByMmsiParams = {
shipNameHint?: string; shipNameHint?: string;
shipKindCodeHint?: string; shipKindCodeHint?: string;
nationalCodeHint?: string; nationalCodeHint?: string;
isPermitted?: boolean;
}; };
type V2TrackResponse = { type V2TrackResponse = {
@ -24,6 +23,12 @@ type V2TrackResponse = {
avgSpeed?: number; avgSpeed?: number;
maxSpeed?: number; maxSpeed?: number;
pointCount?: number; pointCount?: number;
chnPrmShipInfo?: {
name?: string;
vesselType?: string;
callsign?: string;
imo?: number;
};
}; };
function normalizeTimestampMs(value: string | number): number { function normalizeTimestampMs(value: string | number): number {
@ -77,11 +82,13 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
const targetId = row.targetId || row.vesselId || ''; const targetId = row.targetId || row.vesselId || '';
const sigSrcCd = row.sigSrcCd || '000001'; const sigSrcCd = row.sigSrcCd || '000001';
const chnName = row.chnPrmShipInfo?.name?.trim();
out.push({ out.push({
vesselId: row.vesselId || `${sigSrcCd}_${targetId}`, vesselId: row.vesselId || `${sigSrcCd}_${targetId}`,
targetId, targetId,
sigSrcCd, sigSrcCd,
shipName: (row.shipName || '').trim() || targetId, shipName: chnName || (row.shipName || '').trim() || targetId,
shipKindCode: row.shipKindCode || '000027', shipKindCode: row.shipKindCode || '000027',
nationalCode: row.nationalCode || '', nationalCode: row.nationalCode || '',
geometry, geometry,
@ -99,33 +106,17 @@ function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
return out; return out;
} }
async function queryLegacyTrack(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const response = await fetchVesselTrack(params.mmsi, params.minutes);
if (!response.success || response.data.length === 0) return [];
const converted = convertLegacyTrackPointsToProcessedTrack(params.mmsi, response.data, {
shipName: params.shipNameHint,
shipKindCode: params.shipKindCodeHint,
nationalCode: params.nationalCodeHint,
});
return converted ? [converted] : [];
}
async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> { async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '').trim(); const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim();
if (!base) {
return queryLegacyTrack(params);
}
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);
const requestBody = { const requestBody = {
startTime: start.toISOString().slice(0, 19), startTime: start.toISOString(),
endTime: end.toISOString().slice(0, 19), endTime: end.toISOString(),
vessels: [{ sigSrcCd: '000001', targetId: String(params.mmsi) }], vessels: [String(params.mmsi)],
isIntegration: '0', includeChnPrmShip: params.isPermitted ?? false,
}; };
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`; const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
@ -136,7 +127,7 @@ async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTr
}); });
if (!res.ok) { if (!res.ok) {
return queryLegacyTrack(params); throw new Error(`Track API ${res.status}`);
} }
const json = (await res.json()) as unknown; const json = (await res.json()) as unknown;
@ -146,17 +137,9 @@ async function queryV2Track(params: QueryTrackByMmsiParams): Promise<ProcessedTr
? ((json as { data: V2TrackResponse[] }).data) ? ((json as { data: V2TrackResponse[] }).data)
: []; : [];
const converted = convertV2Tracks(rows); return convertV2Tracks(rows);
if (converted.length > 0) return converted;
return queryLegacyTrack(params);
} }
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> { export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const mode = String(import.meta.env.VITE_TRACK_SOURCE_MODE || 'legacy').toLowerCase(); return queryV2Track(params);
if (mode === 'v2') {
return queryV2Track(params);
}
return queryLegacyTrack(params);
} }

파일 보기

@ -18,6 +18,8 @@ import { Topbar } from "../../widgets/topbar/Topbar";
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
import { DepthLegend } from "../../widgets/legend/DepthLegend"; import { DepthLegend } from "../../widgets/legend/DepthLegend";
import type { ShipImageInfo } from "../../entities/shipImage/model/types";
import ShipImageModal from "../../widgets/shipImage/ShipImageModal";
import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService"; import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService";
import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore"; import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore";
import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel"; import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel";
@ -41,12 +43,6 @@ import { useDashboardState } from "./useDashboardState";
import type { Bbox } from "./useDashboardState"; import type { Bbox } from "./useDashboardState";
import { DashboardSidebar } from "./DashboardSidebar"; import { DashboardSidebar } from "./DashboardSidebar";
const AIS_CENTER = {
lon: 126.95,
lat: 35.95,
radiusMeters: 2_000_000,
};
function inBbox(lon: number, lat: number, bbox: Bbox) { function inBbox(lon: number, lat: number, bbox: Bbox) {
const [lonMin, latMin, lonMax, latMax] = bbox; const [lonMin, latMin, lonMax, latMax] = bbox;
if (lat < latMin || lat > latMax) return false; if (lat < latMin || lat > latMax) return false;
@ -108,40 +104,11 @@ export function DashboardPage() {
// ── AIS polling ── // ── AIS polling ──
const { targets, snapshot } = useAisTargetPolling({ const { targets, snapshot } = useAisTargetPolling({
chnprmshipMinutes: 120, chnprmship: { initialMinutes: 120, pollMinutes: 2, intervalMs: 60_000, retentionMinutes: 120 },
incrementalMinutes: 2, recent: { initialMinutes: 15, pollMinutes: 12, intervalMs: 600_000, retentionMinutes: 72 },
intervalMs: 60_000,
retentionMinutes: 120,
bbox: useApiBbox ? apiBbox : undefined, bbox: useApiBbox ? apiBbox : undefined,
centerLon: useApiBbox ? undefined : AIS_CENTER.lon,
centerLat: useApiBbox ? undefined : AIS_CENTER.lat,
radiusMeters: useApiBbox ? undefined : AIS_CENTER.radiusMeters,
}); });
// ── Track request ──
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
const trackStore = useTrackQueryStore.getState();
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
trackStore.beginQuery(queryKey);
try {
const target = targets.find((item) => item.mmsi === mmsi);
const tracks = await queryTrackByMmsi({
mmsi,
minutes,
shipNameHint: target?.name,
});
if (tracks.length > 0) {
trackStore.applyTracksSuccess(tracks, queryKey);
} else {
trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
}, [targets]);
// ── Derived data ── // ── Derived data ──
const targetsInScope = useMemo(() => { const targetsInScope = useMemo(() => {
const base = (!useViewportFilter || !viewBbox) const base = (!useViewportFilter || !viewBbox)
@ -157,6 +124,35 @@ export function DashboardPage() {
} }
return hits; return hits;
}, [targetsInScope, legacyIndex, isDevMode]); }, [targetsInScope, legacyIndex, isDevMode]);
// ── Track request ──
const handleRequestTrack = useCallback(async (mmsi: number, minutes: number) => {
const trackStore = useTrackQueryStore.getState();
const queryKey = `${mmsi}:${minutes}:${Date.now()}`;
trackStore.beginQuery(queryKey);
try {
const target = targets.find((item) => item.mmsi === mmsi);
const isPermitted = legacyHits.has(mmsi);
const tracks = await queryTrackByMmsi({
mmsi,
minutes,
shipNameHint: target?.name,
shipKindCodeHint: target?.shipKindCode,
nationalCodeHint: target?.nationalCode,
isPermitted,
});
if (tracks.length > 0) {
trackStore.applyTracksSuccess(tracks, queryKey);
} else {
trackStore.applyQueryError('항적 데이터가 없습니다.', queryKey);
}
} catch (e) {
trackStore.applyQueryError(e instanceof Error ? e.message : '항적 조회에 실패했습니다.', queryKey);
}
}, [targets, legacyHits]);
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]); const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]); const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
@ -244,6 +240,44 @@ export function DashboardPage() {
[highlightedMmsiSet, availableTargetMmsiSet], [highlightedMmsiSet, availableTargetMmsiSet],
); );
// 모달 상태
const [imageModal, setImageModal] = useState<{
images?: ShipImageInfo[];
initialIndex?: number;
initialImagePath?: string;
totalCount?: number;
imo?: number;
vesselName?: string;
} | null>(null);
const handleOpenImageModal = useCallback((mmsi: number) => {
const target = targetsInScope.find((t) => t.mmsi === mmsi);
if (!target?.shipImagePath) return;
const vessel = legacyVesselsAll.find((v) => v.mmsi === mmsi);
const vesselName = vessel?.name || target.name || '';
setImageModal({
initialImagePath: target.shipImagePath,
totalCount: target.shipImageCount ?? 1,
imo: target.imo > 0 ? target.imo : undefined,
vesselName,
});
}, [targetsInScope, legacyVesselsAll]);
const handlePanelOpenImageModal = useCallback((index: number, images?: ShipImageInfo[]) => {
if (!selectedMmsi) return;
const target = targetsInScope.find((t) => t.mmsi === selectedMmsi);
const vessel = legacyVesselsAll.find((v) => v.mmsi === selectedMmsi);
const vesselName = vessel?.name || target?.name || '';
setImageModal({
images,
initialIndex: index,
initialImagePath: target?.shipImagePath ?? undefined,
totalCount: target?.shipImageCount ?? 1,
imo: target && target.imo > 0 ? target.imo : undefined,
vesselName,
});
}, [selectedMmsi, targetsInScope, legacyVesselsAll]);
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length; const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length; const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
@ -370,6 +404,7 @@ export function DashboardPage() {
onOpenTrackMenu={handleOpenTrackMenu} onOpenTrackMenu={handleOpenTrackMenu}
onMapReady={handleMapReady} onMapReady={handleMapReady}
alarmMmsiMap={alarmMmsiMap} alarmMmsiMap={alarmMmsiMap}
onClickShipPhoto={handleOpenImageModal}
/> />
<GlobalTrackReplayPanel /> <GlobalTrackReplayPanel />
<WeatherPanel <WeatherPanel
@ -383,10 +418,21 @@ export function DashboardPage() {
<DepthLegend depthStops={mapStyleSettings.depthStops} /> <DepthLegend depthStops={mapStyleSettings.depthStops} />
<MapLegend /> <MapLegend />
{selectedLegacyVessel ? ( {selectedLegacyVessel ? (
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} /> <VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} />
) : selectedTarget ? ( ) : selectedTarget ? (
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} /> <AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} />
) : null} ) : null}
{imageModal && (
<ShipImageModal
images={imageModal.images}
initialIndex={imageModal.initialIndex}
initialImagePath={imageModal.initialImagePath}
totalCount={imageModal.totalCount}
imo={imageModal.imo}
vesselName={imageModal.vesselName}
onClose={() => setImageModal(null)}
/>
)}
{selectedCableId && subcableData?.details.get(selectedCableId) ? ( {selectedCableId && subcableData?.details.get(selectedCableId) ? (
<SubcableInfoPanel <SubcableInfoPanel
detail={subcableData.details.get(selectedCableId)!} detail={subcableData.details.get(selectedCableId)!}

파일 보기

@ -37,7 +37,7 @@ export function useDashboardState(uid: number | null) {
uid, 'typeEnabled', { PT: true, 'PT-S': true, GN: true, OT: true, PS: true, FC: true }, uid, 'typeEnabled', { PT: true, 'PT-S': true, GN: true, OT: true, PS: true, FC: true },
); );
const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true); const [showTargets, setShowTargets] = usePersistedState(uid, 'showTargets', true);
const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', false); const [showOthers, setShowOthers] = usePersistedState(uid, 'showOthers', true);
// ── Map settings (persisted) ── // ── Map settings (persisted) ──
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용 // 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
@ -47,7 +47,7 @@ export function useDashboardState(uid: number | null) {
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', { const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true, pairLines: true, pairRange: true, fcLines: true, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, shipPhotos: true,
}); });
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', { const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false, showShips: true, showDensity: false, showSeamark: false,

파일 보기

@ -1,14 +1,17 @@
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 type { ShipImageInfo } from "../../entities/shipImage/model/types";
import { fmtIsoFull } from "../../shared/lib/datetime"; import { fmtIsoFull } from "../../shared/lib/datetime";
import ShipImageGallery from "../shipImage/ShipImageGallery";
type Props = { type Props = {
target: AisTarget; target: AisTarget;
legacy?: LegacyVesselInfo | null; legacy?: LegacyVesselInfo | null;
onClose: () => void; onClose: () => void;
onOpenImageModal?: (index: number, images?: ShipImageInfo[]) => void;
}; };
export function AisInfoPanel({ target: t, legacy, onClose }: Props) { export function AisInfoPanel({ target: t, legacy, onClose, onOpenImageModal }: Props) {
const name = (t.name || "").trim() || "(no name)"; const name = (t.name || "").trim() || "(no name)";
return ( return (
<div className="map-info"> <div className="map-info">
@ -25,6 +28,10 @@ export function AisInfoPanel({ target: t, legacy, onClose }: Props) {
</div> </div>
</div> </div>
{t.shipImagePath && (
<ShipImageGallery imo={t.imo > 0 ? t.imo : undefined} initialImagePath={t.shipImagePath} totalCount={t.shipImageCount} onOpenModal={onOpenImageModal} />
)}
{legacy ? ( {legacy ? (
<div style={{ marginBottom: 8, padding: "8px 8px", borderRadius: 8, border: "1px solid rgba(245,158,11,.35)", background: "rgba(245,158,11,.06)" }}> <div style={{ marginBottom: 8, padding: "8px 8px", borderRadius: 8, border: "1px solid rgba(245,158,11,.35)", background: "rgba(245,158,11,.06)" }}>
<div style={{ fontSize: 10, fontWeight: 900, color: "#F59E0B", marginBottom: 4 }}>CN Permit Match</div> <div style={{ fontSize: 10, fontWeight: 900, color: "#F59E0B", marginBottom: 4 }}>CN Permit Match</div>

파일 보기

@ -1,18 +1,24 @@
import { ZONE_META } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta";
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta"; import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { ShipImageInfo } from "../../entities/shipImage/model/types";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import { fmtIsoFull } from "../../shared/lib/datetime"; import { fmtIsoFull } from "../../shared/lib/datetime";
import { haversineNm } from "../../shared/lib/geo/haversineNm"; import { haversineNm } from "../../shared/lib/geo/haversineNm";
import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette"; import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette";
import ShipImageGallery from "../shipImage/ShipImageGallery";
type Props = { type Props = {
vessel: DerivedLegacyVessel; vessel: DerivedLegacyVessel;
allVessels: DerivedLegacyVessel[]; allVessels: DerivedLegacyVessel[];
onClose: () => void; onClose: () => void;
onSelectMmsi?: (mmsi: number) => void; onSelectMmsi?: (mmsi: number) => void;
imo?: number;
shipImagePath?: string | null;
shipImageCount?: number;
onOpenImageModal?: (index: number, images?: ShipImageInfo[]) => void;
}; };
export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }: Props) { export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi, imo, shipImagePath, shipImageCount, onOpenImageModal }: Props) {
const t = VESSEL_TYPES[v.shipCode]; const t = VESSEL_TYPES[v.shipCode];
const ownerLabel = v.ownerCn || v.ownerRoman || v.ownerKey || "-"; const ownerLabel = v.ownerCn || v.ownerRoman || v.ownerKey || "-";
const primary = t.speedProfile.filter((s) => s.primary); const primary = t.speedProfile.filter((s) => s.primary);
@ -44,6 +50,10 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
</div> </div>
</div> </div>
{shipImagePath && (
<ShipImageGallery imo={imo} initialImagePath={shipImagePath} totalCount={shipImageCount} onOpenModal={onOpenImageModal} />
)}
<div className="ir"> <div className="ir">
<span className="il"></span> <span className="il"></span>
<span className="iv" style={{ color: inRange ? "#22C55E" : "var(--muted)" }}> <span className="iv" style={{ color: inRange ? "#22C55E" : "var(--muted)" }}>

파일 보기

@ -82,6 +82,7 @@ export function Map3D({
onOpenTrackMenu, onOpenTrackMenu,
onMapReady, onMapReady,
alarmMmsiMap, alarmMmsiMap,
onClickShipPhoto,
}: Props) { }: Props) {
// ── Shared refs ────────────────────────────────────────────────────── // ── Shared refs ──────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -610,6 +611,7 @@ export function Map3D({
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi, onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap, ensureMercatorOverlay, alarmMmsiMap,
onClickShipPhoto,
}, },
); );

파일 보기

@ -43,7 +43,7 @@ export const HALO_OUTLINE_COLOR: [number, number, number, number] = [210, 225, 2
export const HALO_OUTLINE_COLOR_SELECTED: [number, number, number, number] = [14, 234, 255, 230]; export const HALO_OUTLINE_COLOR_SELECTED: [number, number, number, number] = [14, 234, 255, 230];
export const HALO_OUTLINE_COLOR_HIGHLIGHTED: [number, number, number, number] = [245, 158, 11, 210]; export const HALO_OUTLINE_COLOR_HIGHLIGHTED: [number, number, number, number] = [245, 158, 11, 210];
export const GLOBE_OUTLINE_PERMITTED = 'rgba(210,225,240,0.62)'; export const GLOBE_OUTLINE_PERMITTED = 'rgba(210,225,240,0.62)';
export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.35)'; export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)';
// ── Flat map icon sizes ── // ── Flat map icon sizes ──

파일 보기

@ -19,7 +19,6 @@ 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';
// 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.
@ -69,6 +68,7 @@ export function useDeckLayers(
onToggleHighlightMmsi?: (mmsi: number) => void; onToggleHighlightMmsi?: (mmsi: number) => void;
ensureMercatorOverlay: () => MapboxOverlay | null; ensureMercatorOverlay: () => MapboxOverlay | null;
alarmMmsiMap?: Map<number, LegacyAlarmKind>; alarmMmsiMap?: Map<number, LegacyAlarmKind>;
onClickShipPhoto?: (mmsi: number) => void;
}, },
) { ) {
const { const {
@ -82,6 +82,7 @@ export function useDeckLayers(
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap, ensureMercatorOverlay, alarmMmsiMap,
onClickShipPhoto,
} = opts; } = opts;
const legacyTargets = useMemo(() => { const legacyTargets = useMemo(() => {
@ -106,6 +107,10 @@ export function useDeckLayers(
return shipData.filter((t) => alarmMmsiMap.has(t.mmsi)); return shipData.filter((t) => alarmMmsiMap.has(t.mmsi));
}, [shipData, alarmMmsiMap]); }, [shipData, alarmMmsiMap]);
const shipPhotoTargets = useMemo(() => {
return shipData.filter((t) => !!t.shipImagePath);
}, [shipData]);
const mercatorLayersRef = useRef<unknown[]>([]); const mercatorLayersRef = useRef<unknown[]>([]);
const alarmRafRef = useRef(0); const alarmRafRef = useRef(0);
@ -161,6 +166,8 @@ export function useDeckLayers(
alarmMmsiMap, alarmMmsiMap,
alarmPulseRadius: 8, alarmPulseRadius: 8,
alarmPulseHoverRadius: 12, alarmPulseHoverRadius: 12,
shipPhotoTargets,
onClickShipPhoto,
}); });
const normalizedBaseLayers = sanitizeDeckLayerList(layers); const normalizedBaseLayers = sanitizeDeckLayerList(layers);
@ -264,6 +271,9 @@ export function useDeckLayers(
hasAuxiliarySelectModifier, hasAuxiliarySelectModifier,
alarmTargets, alarmTargets,
alarmMmsiMap, alarmMmsiMap,
shipPhotoTargets,
onClickShipPhoto,
overlays.shipPhotos,
]); ]);
// Mercator alarm pulse breathing animation (rAF) // Mercator alarm pulse breathing animation (rAF)
@ -337,6 +347,8 @@ export function useDeckLayers(
if (!deckTarget) return; if (!deckTarget) return;
if (!ENABLE_GLOBE_DECK_OVERLAYS) { if (!ENABLE_GLOBE_DECK_OVERLAYS) {
// Globe에서는 Deck.gl ScatterplotLayer가 프로젝션 공간 아티팩트(막대)를 유발하므로
// 빈 레이어만 설정. 사진 인디케이터는 Mercator에서만 동작.
try { try {
deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never); deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never);
} catch { } catch {

파일 보기

@ -120,6 +120,7 @@ export function useGlobeShipLayers(
alarmKind: alarmKind ?? '', alarmKind: alarmKind ?? '',
alarmBadgeLabel: alarmKind ? ALARM_BADGE[alarmKind].label : '', alarmBadgeLabel: alarmKind ? ALARM_BADGE[alarmKind].label : '',
alarmBadgeColor: alarmKind ? ALARM_BADGE[alarmKind].color : '#000', alarmBadgeColor: alarmKind ? ALARM_BADGE[alarmKind].color : '#000',
hasPhoto: t.shipImagePath ? 1 : 0,
}, },
}; };
}), }),
@ -167,13 +168,14 @@ export function useGlobeShipLayers(
const symbolLiteId = 'ships-globe-lite'; const symbolLiteId = 'ships-globe-lite';
const symbolId = 'ships-globe'; const symbolId = 'ships-globe';
const labelId = 'ships-globe-label'; const labelId = 'ships-globe-label';
const photoId = 'ships-globe-photo';
const pulseId = 'ships-globe-alarm-pulse'; const pulseId = 'ships-globe-alarm-pulse';
const badgeId = 'ships-globe-alarm-badge'; const badgeId = 'ships-globe-alarm-badge';
// 레이어를 제거하지 않고 visibility만 'none'으로 설정 // 레이어를 제거하지 않고 visibility만 'none'으로 설정
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지) // guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
const hide = () => { const hide = () => {
for (const id of [badgeId, labelId, symbolId, symbolLiteId, pulseId, outlineId, haloId]) { for (const id of [badgeId, photoId, labelId, symbolId, symbolLiteId, pulseId, outlineId, haloId]) {
guardedSetVisibility(map, id, 'none'); guardedSetVisibility(map, id, 'none');
} }
}; };
@ -197,6 +199,7 @@ export function useGlobeShipLayers(
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지 // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
const photoVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipPhotos ? 'visible' : 'none';
if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) { if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) {
const changed = const changed =
map.getLayoutProperty(symbolId, 'visibility') !== visibility || map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
@ -208,6 +211,7 @@ export function useGlobeShipLayers(
if (projection === 'globe') kickRepaint(map); if (projection === 'globe') kickRepaint(map);
} }
guardedSetVisibility(map, labelId, labelVisibility); guardedSetVisibility(map, labelId, labelVisibility);
guardedSetVisibility(map, photoId, photoVisibility);
} }
// 데이터 업데이트는 projectionBusy 중에는 차단 // 데이터 업데이트는 projectionBusy 중에는 차단
@ -298,7 +302,8 @@ export function useGlobeShipLayers(
'case', 'case',
['==', ['feature-state', 'selected'], 1], 0.38, ['==', ['feature-state', 'selected'], 1], 0.38,
['==', ['feature-state', 'highlighted'], 1], 0.34, ['==', ['feature-state', 'highlighted'], 1], 0.34,
0.16, ['==', ['get', 'permitted'], 1], 0.16,
0.25,
] as never, ] as never,
}, },
} as unknown as LayerSpecification, } as unknown as LayerSpecification,
@ -332,7 +337,7 @@ export function useGlobeShipLayers(
['==', ['feature-state', 'selected'], 1], 3.4, ['==', ['feature-state', 'selected'], 1], 3.4,
['==', ['feature-state', 'highlighted'], 1], 2.7, ['==', ['feature-state', 'highlighted'], 1], 2.7,
['==', ['get', 'permitted'], 1], 1.8, ['==', ['get', 'permitted'], 1], 1.8,
0.7, 1.2,
] as never, ] as never,
'circle-stroke-opacity': 0.85, 'circle-stroke-opacity': 0.85,
}, },
@ -433,13 +438,13 @@ export function useGlobeShipLayers(
['linear'], ['linear'],
['zoom'], ['zoom'],
6.5, 6.5,
0.16, 0.28,
8, 8,
0.34, 0.45,
11, 11,
0.54, 0.65,
14, 14,
0.68, 0.78,
] as never, ] as never,
}, },
} as unknown as LayerSpecification, } as unknown as LayerSpecification,
@ -511,6 +516,34 @@ export function useGlobeShipLayers(
} }
} }
// Photo indicator circle (above ship icons, below labels)
if (!map.getLayer(photoId)) {
needReorder = true;
try {
map.addLayer(
{
id: photoId,
type: 'circle',
source: srcId,
filter: ['==', ['get', 'hasPhoto'], 1] as never,
layout: { visibility: photoVisibility },
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
3, 3, 7, 4, 10, 5, 14, 6,
] as never,
'circle-color': 'rgba(0, 188, 212, 0.7)',
'circle-stroke-color': 'rgba(255, 255, 255, 0.8)',
'circle-stroke-width': 1,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn('Ship photo indicator layer add failed:', e);
}
}
const labelFilter = [ const labelFilter = [
'all', 'all',
['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''],
@ -610,6 +643,7 @@ export function useGlobeShipLayers(
projection, projection,
settings.showShips, settings.showShips,
overlays.shipLabels, overlays.shipLabels,
overlays.shipPhotos,
globeShipGeoJson, globeShipGeoJson,
alarmGeoJson, alarmGeoJson,
mapSyncEpoch, mapSyncEpoch,

파일 보기

@ -85,6 +85,8 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect
alarmMmsiMap?: Map<number, LegacyAlarmKind>; alarmMmsiMap?: Map<number, LegacyAlarmKind>;
alarmPulseRadius?: number; alarmPulseRadius?: number;
alarmPulseHoverRadius?: number; alarmPulseHoverRadius?: number;
shipPhotoTargets?: AisTarget[];
onClickShipPhoto?: (mmsi: number) => void;
} }
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] { export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
@ -316,6 +318,26 @@ 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',
@ -529,6 +551,31 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
); );
} }
/* ─ ship photo indicator (사진 유무 표시) ─ */
const photoTargets = ctx.shipPhotoTargets ?? [];
if (ctx.showShips && ctx.overlays.shipPhotos && photoTargets.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
id: 'ship-photo-indicator',
data: photoTargets,
pickable: true,
billboard: false,
filled: true,
stroked: true,
radiusUnits: 'pixels',
getRadius: 5,
getFillColor: [0, 188, 212, 180],
getLineColor: [255, 255, 255, 200],
lineWidthUnits: 'pixels',
getLineWidth: 1,
getPosition: (d) => [d.lon, d.lat] as [number, number],
onClick: (info: PickingInfo) => {
if (info.object) ctx.onClickShipPhoto?.((info.object as AisTarget).mmsi);
},
}),
);
}
return layers; return layers;
} }

파일 보기

@ -87,10 +87,10 @@ export function getShipColor(
if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
return [245, 158, 11, 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], 130]; 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, 185]; 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], 175]; 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, 165]; return [71, 85, 105, 200];
} }
export function buildGlobeShipFeature( export function buildGlobeShipFeature(

파일 보기

@ -1,5 +1,6 @@
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 { toThumbnailUrl } from '../../../entities/shipImage/api/fetchShipImages';
import { fmtIsoFull } from '../../../shared/lib/datetime'; import { fmtIsoFull } from '../../../shared/lib/datetime';
import { isFiniteNumber, toSafeNumber } from './setUtils'; import { isFiniteNumber, toSafeNumber } from './setUtils';
@ -50,8 +51,16 @@ export function getShipTooltipHtml({
</div>` </div>`
: ''; : '';
const imgPath = t?.shipImagePath;
const photoHtml = imgPath
? `<div style="margin: 0 0 6px; text-align: center;">
<img src="${toThumbnailUrl(imgPath)}" alt="" style="width: 140px; height: 90px; object-fit: cover; border-radius: 6px; display: inline-block; border: 1px solid rgba(255,255,255,.12);" />
</div>`
: '';
return { return {
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;"> html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
${photoHtml}
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div> <div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
<div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div> <div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div>
<div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div> <div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div>

파일 보기

@ -71,6 +71,8 @@ export interface Map3DProps {
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void; onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */ /** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
alarmMmsiMap?: Map<number, LegacyAlarmKind>; alarmMmsiMap?: Map<number, LegacyAlarmKind>;
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */
onClickShipPhoto?: (mmsi: number) => void;
} }
export type DashSeg = { export type DashSeg = {

파일 보기

@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react';
import type { ShipImageInfo } from '../../entities/shipImage/model/types';
import { fetchShipImagesByImo, toThumbnailUrl } from '../../entities/shipImage/api/fetchShipImages';
interface ShipImageGalleryProps {
imo?: number;
initialImagePath?: string | null;
totalCount?: number;
onOpenModal?: (index: number, images?: ShipImageInfo[]) => void;
}
const SCROLL_STEP = 86; // 80px thumb + 6px gap
const ShipImageGallery = ({ imo, initialImagePath, totalCount, onOpenModal }: ShipImageGalleryProps) => {
const [images, setImages] = useState<ShipImageInfo[] | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
useEffect(() => {
if (!imo || imo <= 0) return;
const ac = new AbortController();
fetchShipImagesByImo(imo, ac.signal).then((result) => {
if (ac.signal.aborted) return;
if (result.length > 0) setImages(result);
});
return () => ac.abort();
}, [imo]);
const updateScrollButtons = () => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
useEffect(() => {
const el = scrollRef.current;
if (!el || !images) return;
const raf = requestAnimationFrame(updateScrollButtons);
el.addEventListener('scroll', updateScrollButtons, { passive: true });
return () => {
cancelAnimationFrame(raf);
el.removeEventListener('scroll', updateScrollButtons);
};
}, [images]);
const handleScroll = (dir: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: dir === 'left' ? -SCROLL_STEP : SCROLL_STEP, behavior: 'smooth' });
};
// 전체 이미지 로드 완료
if (images && images.length > 0) {
const showArrows = images.length > 3;
return (
<div className="ship-image-gallery-wrap">
{showArrows && canScrollLeft && (
<button
className="ship-image-gallery__arrow ship-image-gallery__arrow--left"
onClick={() => handleScroll('left')}
aria-label="이전 사진"
>
</button>
)}
<div className="ship-image-gallery" ref={scrollRef}>
{images.map((img, i) => (
<button
key={img.picId}
className="ship-image-gallery__thumb"
onClick={() => onOpenModal?.(i, images)}
aria-label={`선박 사진 ${i + 1}`}
>
<img
src={toThumbnailUrl(img.path)}
alt={`사진 ${i + 1}`}
loading="lazy"
/>
</button>
))}
</div>
{showArrows && canScrollRight && (
<button
className="ship-image-gallery__arrow ship-image-gallery__arrow--right"
onClick={() => handleScroll('right')}
aria-label="다음 사진"
>
</button>
)}
</div>
);
}
// fallback: 단일 이미지 (API 로딩 중이거나 imo 없음)
if (!initialImagePath) return null;
const count = totalCount ?? 1;
return (
<div className="ship-image-gallery">
<button
className="ship-image-gallery__thumb"
onClick={() => onOpenModal?.(0)}
aria-label="선박 사진 1"
style={{ position: 'relative' }}
>
<img
src={toThumbnailUrl(initialImagePath)}
alt="사진 1"
loading="lazy"
/>
{count > 1 && (
<span style={{
position: 'absolute', top: 2, right: 2,
background: 'rgba(30,120,255,0.92)', color: '#fff',
borderRadius: '50%', width: 18, height: 18,
fontSize: 10, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{count > 9 ? '9+' : count}
</span>
)}
</button>
</div>
);
};
export default ShipImageGallery;

파일 보기

@ -0,0 +1,143 @@
import { useCallback, useEffect, useState } from 'react';
import type { ShipImageInfo } from '../../entities/shipImage/model/types';
import { fetchShipImagesByImo, toHighResUrl } from '../../entities/shipImage/api/fetchShipImages';
interface ShipImageModalProps {
/** 갤러리에서 전달받은 전체 이미지 목록 (있으면 API 호출 생략) */
images?: ShipImageInfo[];
/** 시작 인덱스 */
initialIndex?: number;
/** fallback: 첫 번째 이미지 경로 (images 없을 때) */
initialImagePath?: string;
/** 전체 이미지 수 (images 없을 때 표시용) */
totalCount?: number;
/** IMO — images 없을 때 API 호출용 */
imo?: number;
vesselName?: string;
onClose: () => void;
}
const ShipImageModal = ({
images: preloadedImages,
initialIndex = 0,
initialImagePath,
totalCount,
imo,
vesselName,
onClose,
}: ShipImageModalProps) => {
const [index, setIndex] = useState(initialIndex);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
// 전체 이미지 목록: preloaded가 있으면 그것을 사용, 없으면 API로 로드
const [fetchedImages, setFetchedImages] = useState<ShipImageInfo[] | null>(null);
const needsFetch = !preloadedImages && !!imo && imo > 0;
const [fetchingAll, setFetchingAll] = useState(needsFetch);
const allImages = preloadedImages ?? fetchedImages;
const total = allImages ? allImages.length : (totalCount ?? 1);
const hasPrev = index > 0;
const hasNext = index < total - 1;
// 현재 이미지 URL 결정 (모달은 항상 고화질)
const currentImageUrl = (() => {
if (allImages && allImages[index]) return toHighResUrl(allImages[index].path);
if (index === 0 && initialImagePath) return toHighResUrl(initialImagePath);
return null;
})();
// preloaded 없을 때만 API 호출
useEffect(() => {
if (!needsFetch) return;
const ac = new AbortController();
fetchShipImagesByImo(imo!, ac.signal).then((result) => {
if (ac.signal.aborted) return;
setFetchedImages(result.length > 0 ? result : null);
setFetchingAll(false);
});
return () => ac.abort();
}, [needsFetch, imo]);
const goPrev = useCallback(() => {
if (hasPrev) { setIndex((i) => i - 1); setLoading(true); setError(false); }
}, [hasPrev]);
const goNext = useCallback(() => {
if (!hasNext) return;
setIndex((i) => i + 1);
setLoading(true);
setError(false);
}, [hasNext]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft') goPrev();
else if (e.key === 'ArrowRight') goNext();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, goPrev, goNext]);
// 현재 이미지 메타데이터
const currentMeta = allImages?.[index] ?? null;
return (
<div className="ship-image-modal" onClick={onClose}>
<div className="ship-image-modal__content" onClick={(e) => e.stopPropagation()}>
{/* 헤더 */}
<div className="ship-image-modal__header">
<span className="ship-image-modal__title">
{vesselName && <strong>{vesselName}</strong>}
{total > 1 && <span className="ship-image-modal__counter">{index + 1} / {total}</span>}
</span>
<button className="ship-image-modal__close" onClick={onClose} aria-label="닫기">
</button>
</div>
{/* 이미지 영역 */}
<div className="ship-image-modal__body">
{hasPrev && (
<button className="ship-image-modal__nav ship-image-modal__nav--prev" onClick={goPrev} aria-label="이전">
</button>
)}
<div className="ship-image-modal__img-wrap">
{(loading || fetchingAll) && !error && <div className="ship-image-modal__spinner" />}
{error && <div className="ship-image-modal__error"> </div>}
{currentImageUrl ? (
<img
key={currentImageUrl}
src={currentImageUrl}
alt={vesselName || '선박 사진'}
className="ship-image-modal__img"
style={{ opacity: loading ? 0 : 1 }}
onLoad={() => setLoading(false)}
onError={() => { setLoading(false); setError(true); }}
/>
) : fetchingAll ? null : (
<div className="ship-image-modal__error"> </div>
)}
</div>
{hasNext && (
<button className="ship-image-modal__nav ship-image-modal__nav--next" onClick={goNext} aria-label="다음">
</button>
)}
</div>
{/* 푸터 */}
<div className="ship-image-modal__footer">
{currentMeta?.copyright && <span>{currentMeta.copyright}</span>}
{currentMeta?.date && <span>{currentMeta.date}</span>}
</div>
</div>
</div>
);
};
export default ShipImageModal;

파일 보기

@ -10,10 +10,9 @@ export default defineConfig(({ mode }) => {
const webPort = Number(env.WEB_PORT || process.env.WEB_PORT || 5175); const webPort = Number(env.WEB_PORT || process.env.WEB_PORT || 5175);
const apiPort = Number(env.API_PORT || process.env.API_PORT || 5174); const apiPort = Number(env.API_PORT || process.env.API_PORT || 5174);
// Same proxy pattern as the "dark" project: // dev: use Vite proxy → upstream
// - dev: use Vite proxy (/snp-api -> upstream host) // prod: set VITE_API_URL to absolute base if needed
// - prod: set VITE_API_URL to absolute base if needed const signalBatchTarget = env.VITE_SIGNAL_BATCH_TARGET || process.env.VITE_SIGNAL_BATCH_TARGET || "https://wing.gc-si.dev";
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
return { return {
plugins: [tailwindcss(), react()], plugins: [tailwindcss(), react()],
@ -35,9 +34,15 @@ export default defineConfig(({ mode }) => {
target: `http://127.0.0.1:${apiPort}`, target: `http://127.0.0.1:${apiPort}`,
changeOrigin: true, changeOrigin: true,
}, },
// SNP-Batch AIS upstream (ship positions) // signal-batch API (선박 위치/항적)
"/snp-api": { "/signal-batch": {
target: snpApiTarget, target: signalBatchTarget,
changeOrigin: true,
secure: false,
},
// 선박 이미지 정적 파일 (nginx alias /pgdata/shipimg/)
"/shipimg": {
target: signalBatchTarget,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },