Compare commits
14 커밋
feature/ov
...
develop
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| de0d74ce3f | |||
| 93db39e0d5 | |||
| 03337fc99c | |||
| d66d0f0c89 | |||
| d5a8be3b96 | |||
| e72e2f14f6 | |||
| 71f235097c | |||
| 1e742e5a3d | |||
| 9437f5be3d | |||
| 7cdc8bef2f | |||
| 59a5e6beac | |||
| f9da13b694 | |||
| 7bca216c53 | |||
| ccaf20804d |
@ -20,9 +20,10 @@ fi
|
|||||||
# Conventional Commits 정규식
|
# Conventional Commits 정규식
|
||||||
# type(scope): subject
|
# type(scope): subject
|
||||||
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||||
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
|
# - scope: 괄호 제외 모든 문자 허용 — 한/영/숫자/특수문자 (선택)
|
||||||
# - subject: 1~72자, 한/영 혼용 허용 (필수)
|
# - subject: 1자 이상 (길이는 바이트 기반 별도 검증)
|
||||||
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$'
|
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([^)]+\))?: .+$'
|
||||||
|
MAX_SUBJECT_BYTES=200 # UTF-8 한글(3byte) 허용: 72문자 ≈ 최대 216byte
|
||||||
|
|
||||||
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
@ -58,3 +59,13 @@ if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
|||||||
echo ""
|
echo ""
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 길이 검증 (바이트 기반 — UTF-8 한글 허용)
|
||||||
|
MSG_LEN=$(echo -n "$FIRST_LINE" | wc -c | tr -d ' ')
|
||||||
|
if [ "$MSG_LEN" -gt "$MAX_SUBJECT_BYTES" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " ✗ 커밋 메시지가 너무 깁니다 (${MSG_LEN}바이트, 최대 ${MAX_SUBJECT_BYTES})"
|
||||||
|
echo " 현재 메시지: $FIRST_LINE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
39
apps/web/src/entities/aisTarget/api/dto.ts
Normal file
39
apps/web/src/entities/aisTarget/api/dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
109
apps/web/src/entities/aisTarget/api/fetchPositions.ts
Normal file
109
apps/web/src/entities/aisTarget/api/fetchPositions.ts
Normal file
@ -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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
35
apps/web/src/entities/shipImage/api/fetchShipImages.ts
Normal file
35
apps/web/src/entities/shipImage/api/fetchShipImages.ts
Normal file
@ -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');
|
||||||
|
};
|
||||||
7
apps/web/src/entities/shipImage/model/types.ts
Normal file
7
apps/web/src/entities/shipImage/model/types.ts
Normal file
@ -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;
|
|
||||||
/** 폴링 주기 ms (기본 60_000) */
|
|
||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
/** 보존 기간 (기본 chnprmshipMinutes) */
|
|
||||||
retentionMinutes?: number;
|
retentionMinutes?: number;
|
||||||
/** incremental 폴링 시 bbox 필터 */
|
};
|
||||||
|
recent?: {
|
||||||
|
initialMinutes?: number;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 동일 소스: 더 최신 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;
|
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 (
|
||||||
|
|||||||
86
apps/web/src/features/shipImage/useShipImageMap.ts
Normal file
86
apps/web/src/features/shipImage/useShipImageMap.ts
Normal file
@ -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,7 +1,6 @@
|
|||||||
import { IconLayer, PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||||
import { DEPTH_DISABLED_PARAMS, SHIP_ICON_MAPPING } from '../../../shared/lib/map/mapConstants';
|
import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants';
|
||||||
import { getCachedShipIcon } from '../../../widgets/map3d/lib/shipIconCache';
|
|
||||||
import { getShipKindColor } from '../lib/adapters';
|
import { getShipKindColor } from '../lib/adapters';
|
||||||
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
|
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
|
||||||
|
|
||||||
@ -139,20 +138,21 @@ export function createDynamicTrackLayers(options: {
|
|||||||
|
|
||||||
if (showVirtualShip) {
|
if (showVirtualShip) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new IconLayer<CurrentVesselPosition>({
|
new ScatterplotLayer<CurrentVesselPosition>({
|
||||||
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
|
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
|
||||||
data: currentPositions,
|
data: currentPositions,
|
||||||
iconAtlas: getCachedShipIcon(),
|
|
||||||
iconMapping: SHIP_ICON_MAPPING,
|
|
||||||
getIcon: () => 'ship',
|
|
||||||
getPosition: (d) => d.position,
|
getPosition: (d) => d.position,
|
||||||
getSize: 22,
|
getFillColor: (d) => {
|
||||||
sizeUnits: 'pixels',
|
|
||||||
getAngle: (d) => -d.heading,
|
|
||||||
getColor: (d) => {
|
|
||||||
const base = getShipKindColor(d.shipKindCode);
|
const base = getShipKindColor(d.shipKindCode);
|
||||||
return [base[0], base[1], base[2], 245] as [number, number, number, number];
|
return [base[0], base[1], base[2], 230] as [number, number, number, number];
|
||||||
},
|
},
|
||||||
|
getLineColor: [255, 255, 255, 200],
|
||||||
|
getRadius: 5,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
radiusMinPixels: 4,
|
||||||
|
radiusMaxPixels: 8,
|
||||||
|
stroked: true,
|
||||||
|
lineWidthMinPixels: 1,
|
||||||
parameters: DEPTH_DISABLED_PARAMS,
|
parameters: DEPTH_DISABLED_PARAMS,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
|
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
|
||||||
@ -183,6 +183,7 @@ export function createDynamicTrackLayers(options: {
|
|||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
getPixelOffset: [14, 0],
|
getPixelOffset: [14, 0],
|
||||||
fontFamily: 'Malgun Gothic, Arial, sans-serif',
|
fontFamily: 'Malgun Gothic, Arial, sans-serif',
|
||||||
|
fontSettings: { sdf: true },
|
||||||
outlineColor: [2, 6, 23, 220],
|
outlineColor: [2, 6, 23, 220],
|
||||||
outlineWidth: 2,
|
outlineWidth: 2,
|
||||||
parameters: DEPTH_DISABLED_PARAMS,
|
parameters: DEPTH_DISABLED_PARAMS,
|
||||||
|
|||||||
@ -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();
|
|
||||||
|
|
||||||
if (mode === 'v2') {
|
|
||||||
return queryV2Track(params);
|
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);
|
||||||
@ -94,6 +95,7 @@ export function Map3D({
|
|||||||
const projectionBusyRef = useRef(false);
|
const projectionBusyRef = useRef(false);
|
||||||
const deckHoverRafRef = useRef<number | null>(null);
|
const deckHoverRafRef = useRef<number | null>(null);
|
||||||
const deckHoverHasHitRef = useRef(false);
|
const deckHoverHasHitRef = useRef(false);
|
||||||
|
const mapInitiatedSelectRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]);
|
useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]);
|
||||||
useEffect(() => { projectionRef.current = projection; }, [projection]);
|
useEffect(() => { projectionRef.current = projection; }, [projection]);
|
||||||
@ -238,12 +240,15 @@ export function Map3D({
|
|||||||
return out;
|
return out;
|
||||||
}, [highlightedMmsiSetForShips, selectedMmsi]);
|
}, [highlightedMmsiSetForShips, selectedMmsi]);
|
||||||
|
|
||||||
|
// Globe: 직접 호버/선택된 선박만 hover overlay에 포함
|
||||||
|
// 선단/쌍 멤버는 feature-state(outline 색상)로 하이라이트 → hover overlay 불필요
|
||||||
|
// → alarm badge 레이어 가림 방지
|
||||||
const shipHoverOverlaySet = useMemo(
|
const shipHoverOverlaySet = useMemo(
|
||||||
() =>
|
() =>
|
||||||
projection === 'globe'
|
projection === 'globe'
|
||||||
? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet)
|
? mergeNumberSets(shipHighlightSet, hoveredDeckMmsiSetRef)
|
||||||
: shipHighlightSet,
|
: shipHighlightSet,
|
||||||
[projection, highlightedMmsiSetCombined, shipHighlightSet],
|
[projection, shipHighlightSet, hoveredDeckMmsiSetRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const shipOverlayLayerData = useMemo(() => {
|
const shipOverlayLayerData = useMemo(() => {
|
||||||
@ -273,6 +278,13 @@ export function Map3D({
|
|||||||
return out;
|
return out;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 지도 내부 클릭에서의 선택 — fly-to 비활성화 플래그 설정
|
||||||
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
|
const onMapSelectMmsi = useCallback((mmsi: number | null) => {
|
||||||
|
mapInitiatedSelectRef.current = true;
|
||||||
|
onSelectMmsi(mmsi);
|
||||||
|
}, [onSelectMmsi]);
|
||||||
|
|
||||||
const onDeckSelectOrHighlight = useCallback(
|
const onDeckSelectOrHighlight = useCallback(
|
||||||
(info: unknown, allowMultiSelect = false) => {
|
(info: unknown, allowMultiSelect = false) => {
|
||||||
const obj = info as {
|
const obj = info as {
|
||||||
@ -288,12 +300,12 @@ export function Map3D({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!allowMultiSelect && selectedMmsi === mmsi) {
|
if (!allowMultiSelect && selectedMmsi === mmsi) {
|
||||||
onSelectMmsi(null);
|
onMapSelectMmsi(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelectMmsi(mmsi);
|
onMapSelectMmsi(mmsi);
|
||||||
},
|
},
|
||||||
[hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
|
[hasAuxiliarySelectModifier, onMapSelectMmsi, onToggleHighlightMmsi, selectedMmsi],
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
@ -562,7 +574,7 @@ export function Map3D({
|
|||||||
{
|
{
|
||||||
projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
|
projection, settings, shipData: shipLayerData, shipHighlightSet, shipHoverOverlaySet,
|
||||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||||
onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
||||||
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||||
onGlobeShipsReady, alarmMmsiMap,
|
onGlobeShipsReady, alarmMmsiMap,
|
||||||
},
|
},
|
||||||
@ -597,8 +609,9 @@ export function Map3D({
|
|||||||
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState,
|
||||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
|
||||||
ensureMercatorOverlay, projectionRef, alarmMmsiMap,
|
ensureMercatorOverlay, alarmMmsiMap,
|
||||||
|
onClickShipPhoto,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -633,22 +646,30 @@ export function Map3D({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!onOpenTrackMenu) return;
|
if (!onOpenTrackMenu) return;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !map.isStyleLoaded() || projectionBusyRef.current) return;
|
if (!map || projectionBusyRef.current) return;
|
||||||
|
|
||||||
let mmsi: number | null = null;
|
let mmsi: number | null = null;
|
||||||
|
|
||||||
if (projectionRef.current === 'globe') {
|
// Globe/Mercator 공통: MapLibre 레이어에서 bbox 쿼리 (호버 상태 무관)
|
||||||
// Globe: MapLibre 네이티브 레이어에서 쿼리
|
let shipLayerIds: string[] = [];
|
||||||
const point: [number, number] = [e.offsetX, e.offsetY];
|
try {
|
||||||
const shipLayerIds = [
|
shipLayerIds = projectionRef.current === 'globe'
|
||||||
|
? [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
].filter((id) => map.getLayer(id));
|
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||||
|
].filter((id) => map.getLayer(id))
|
||||||
|
: [];
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (shipLayerIds.length > 0) {
|
||||||
|
const tolerance = 8;
|
||||||
|
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||||
|
[e.offsetX - tolerance, e.offsetY - tolerance],
|
||||||
|
[e.offsetX + tolerance, e.offsetY + tolerance],
|
||||||
|
];
|
||||||
let features: maplibregl.MapGeoJSONFeature[] = [];
|
let features: maplibregl.MapGeoJSONFeature[] = [];
|
||||||
try {
|
try {
|
||||||
if (shipLayerIds.length > 0) {
|
features = map.queryRenderedFeatures(bbox, { layers: shipLayerIds });
|
||||||
features = map.queryRenderedFeatures(point, { layers: shipLayerIds });
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
@ -656,8 +677,10 @@ export function Map3D({
|
|||||||
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
|
const raw = typeof props.mmsi === 'number' ? props.mmsi : Number(props.mmsi);
|
||||||
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
|
if (Number.isFinite(raw) && raw > 0) mmsi = raw;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Mercator: Deck.gl hover 상태에서 현재 호버된 MMSI 사용
|
|
||||||
|
// Mercator fallback: Deck.gl 호버 상태에서 MMSI 참조
|
||||||
|
if (mmsi == null && projectionRef.current !== 'globe') {
|
||||||
const hovered = hoveredDeckMmsiRef.current;
|
const hovered = hoveredDeckMmsiRef.current;
|
||||||
if (hovered.length > 0) mmsi = hovered[0];
|
if (hovered.length > 0) mmsi = hovered[0];
|
||||||
}
|
}
|
||||||
@ -674,7 +697,7 @@ export function Map3D({
|
|||||||
|
|
||||||
useFlyTo(
|
useFlyTo(
|
||||||
mapRef, projectionRef,
|
mapRef, projectionRef,
|
||||||
{ selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
{ selectedMmsi, shipData, mapInitiatedSelectRef, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출
|
// Map ready 콜백 — mapSyncEpoch 초기 증가 시 1회 호출
|
||||||
|
|||||||
@ -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.
|
||||||
@ -68,8 +67,8 @@ export function useDeckLayers(
|
|||||||
onSelectMmsi: (mmsi: number | null) => void;
|
onSelectMmsi: (mmsi: number | null) => void;
|
||||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||||
projectionRef: MutableRefObject<MapProjectionId>;
|
|
||||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
|
onClickShipPhoto?: (mmsi: number) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
@ -82,7 +81,8 @@ export function useDeckLayers(
|
|||||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||||
ensureMercatorOverlay, projectionRef, alarmMmsiMap,
|
ensureMercatorOverlay, alarmMmsiMap,
|
||||||
|
onClickShipPhoto,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const legacyTargets = useMemo(() => {
|
const legacyTargets = useMemo(() => {
|
||||||
@ -107,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);
|
||||||
|
|
||||||
@ -162,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);
|
||||||
@ -219,12 +225,6 @@ export function useDeckLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelectMmsi(t.mmsi);
|
onSelectMmsi(t.mmsi);
|
||||||
const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
|
||||||
if (projectionRef.current === 'globe') {
|
|
||||||
map.flyTo(clickOpts);
|
|
||||||
} else {
|
|
||||||
map.easeTo(clickOpts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -271,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)
|
||||||
@ -344,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 {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useEffect, type MutableRefObject } from 'react';
|
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
import type maplibregl from 'maplibre-gl';
|
import type maplibregl from 'maplibre-gl';
|
||||||
import { onMapStyleReady } from '../lib/mapCore';
|
|
||||||
import type { MapProjectionId } from '../types';
|
import type { MapProjectionId } from '../types';
|
||||||
|
|
||||||
export function useFlyTo(
|
export function useFlyTo(
|
||||||
@ -9,53 +8,62 @@ export function useFlyTo(
|
|||||||
opts: {
|
opts: {
|
||||||
selectedMmsi: number | null;
|
selectedMmsi: number | null;
|
||||||
shipData: { mmsi: number; lon: number; lat: number }[];
|
shipData: { mmsi: number; lon: number; lat: number }[];
|
||||||
|
/** true일 때 selectedMmsi fly-to 스킵 (지도 클릭 선택 시) */
|
||||||
|
mapInitiatedSelectRef: MutableRefObject<boolean>;
|
||||||
fleetFocusId: string | number | undefined;
|
fleetFocusId: string | number | undefined;
|
||||||
fleetFocusLon: number | undefined;
|
fleetFocusLon: number | undefined;
|
||||||
fleetFocusLat: number | undefined;
|
fleetFocusLat: number | undefined;
|
||||||
fleetFocusZoom: number | undefined;
|
fleetFocusZoom: number | undefined;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
|
const { selectedMmsi, shipData, mapInitiatedSelectRef, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts;
|
||||||
|
|
||||||
|
// shipData를 ref로 — 의존성에서 제외하여 AIS poll마다 재실행 방지
|
||||||
|
const shipDataRef = useRef(shipData);
|
||||||
|
useEffect(() => { shipDataRef.current = shipData; }, [shipData]);
|
||||||
|
|
||||||
|
// 패널(좌측 목록)에서 선택 시 해당 선박 위치로 즉시 이동
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 지도 내부 클릭에서 발생한 선택이면 스킵
|
||||||
|
if (mapInitiatedSelectRef.current) {
|
||||||
|
mapInitiatedSelectRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map || selectedMmsi == null) return;
|
||||||
if (!selectedMmsi) return;
|
|
||||||
const t = shipData.find((x) => x.mmsi === selectedMmsi);
|
const target = shipDataRef.current.find((t) => t.mmsi === selectedMmsi);
|
||||||
if (!t) return;
|
if (!target || !Number.isFinite(target.lon) || !Number.isFinite(target.lat)) return;
|
||||||
const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
|
||||||
|
try {
|
||||||
|
const flyOpts = { center: [target.lon, target.lat] as [number, number], duration: 400 };
|
||||||
if (projectionRef.current === 'globe') {
|
if (projectionRef.current === 'globe') {
|
||||||
map.flyTo(flyOpts);
|
map.flyTo(flyOpts);
|
||||||
} else {
|
} else {
|
||||||
map.easeTo(flyOpts);
|
map.easeTo(flyOpts);
|
||||||
}
|
}
|
||||||
}, [selectedMmsi, shipData]);
|
} catch {
|
||||||
|
// ignore — style not ready 등
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedMmsi]);
|
||||||
|
|
||||||
|
// 선단 포커스 이동
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
|
if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat))
|
||||||
return;
|
return;
|
||||||
const lon = fleetFocusLon;
|
|
||||||
const lat = fleetFocusLat;
|
|
||||||
const zoom = fleetFocusZoom ?? 10;
|
|
||||||
|
|
||||||
const apply = () => {
|
try {
|
||||||
const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
|
const flyOpts = { center: [fleetFocusLon, fleetFocusLat] as [number, number], zoom: fleetFocusZoom ?? 10, duration: 500 };
|
||||||
if (projectionRef.current === 'globe') {
|
if (projectionRef.current === 'globe') {
|
||||||
map.flyTo(flyOpts);
|
map.flyTo(flyOpts);
|
||||||
} else {
|
} else {
|
||||||
map.easeTo(flyOpts);
|
map.easeTo(flyOpts);
|
||||||
}
|
}
|
||||||
};
|
} catch {
|
||||||
|
// ignore
|
||||||
if (map.isStyleLoaded()) {
|
|
||||||
apply();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = onMapStyleReady(map, apply);
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type { DashSeg, MapProjectionId } from '../types';
|
|||||||
import {
|
import {
|
||||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||||
FLEET_FILL_ML_HL,
|
|
||||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { makeUniqueSorted } from '../lib/setUtils';
|
import { makeUniqueSorted } from '../lib/setUtils';
|
||||||
@ -22,7 +21,6 @@ import {
|
|||||||
} from '../lib/mlExpressions';
|
} from '../lib/mlExpressions';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
import { circleRingLngLat } from '../lib/geometry';
|
import { circleRingLngLat } from '../lib/geometry';
|
||||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
|
||||||
import { dashifyLine } from '../lib/dashifyLine';
|
import { dashifyLine } from '../lib/dashifyLine';
|
||||||
|
|
||||||
// ── Overlay line width constants ──
|
// ── Overlay line width constants ──
|
||||||
@ -35,10 +33,10 @@ const FLEET_LINE_W_HL = 3.0;
|
|||||||
const BREATHE_AMP = 2.0;
|
const BREATHE_AMP = 2.0;
|
||||||
const BREATHE_PERIOD_MS = 1200;
|
const BREATHE_PERIOD_MS = 1200;
|
||||||
|
|
||||||
/** Globe FC lines + fleet circles 오버레이 */
|
/** Globe FC lines + fleet circles 오버레이 (stroke only — fill 제거) */
|
||||||
export function useGlobeFcFleetOverlay(
|
export function useGlobeFcFleetOverlay(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
_projectionBusyRef: MutableRefObject<boolean>,
|
||||||
reorderGlobeFeatureLayers: () => void,
|
reorderGlobeFeatureLayers: () => void,
|
||||||
opts: {
|
opts: {
|
||||||
overlays: MapToggleState;
|
overlays: MapToggleState;
|
||||||
@ -57,7 +55,12 @@ export function useGlobeFcFleetOverlay(
|
|||||||
} = opts;
|
} = opts;
|
||||||
const breatheRafRef = useRef<number>(0);
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
|
||||||
// FC lines
|
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용하기 위해 사용
|
||||||
|
const paintStateRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// ── FC lines 데이터 effect ──
|
||||||
|
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
|
||||||
|
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -65,17 +68,11 @@ export function useGlobeFcFleetOverlay(
|
|||||||
const srcId = 'fc-lines-ml-src';
|
const srcId = 'fc-lines-ml-src';
|
||||||
const layerId = 'fc-lines-ml';
|
const layerId = 'fc-lines-ml';
|
||||||
|
|
||||||
const remove = () => {
|
|
||||||
guardedSetVisibility(map, layerId, 'none');
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projection !== 'globe') {
|
||||||
if (!map.isStyleLoaded()) return;
|
try {
|
||||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
const fcHoverActive = fleetAwarePairMmsiList.length > 0;
|
} catch { /* ignore */ }
|
||||||
if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) {
|
|
||||||
remove();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +81,9 @@ export function useGlobeFcFleetOverlay(
|
|||||||
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
||||||
}
|
}
|
||||||
if (segs.length === 0) {
|
if (segs.length === 0) {
|
||||||
remove();
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,12 +107,12 @@ export function useGlobeFcFleetOverlay(
|
|||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
if (existing) existing.setData(fc);
|
if (existing) existing.setData(fc);
|
||||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('FC lines source setup failed:', e);
|
return; // 다음 poll에서 재시도
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getLayer(layerId)) {
|
const needReorder = !map.getLayer(layerId);
|
||||||
|
if (needReorder) {
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -122,74 +121,46 @@ export function useGlobeFcFleetOverlay(
|
|||||||
source: srcId,
|
source: srcId,
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': [
|
'line-color': FC_LINE_NORMAL_ML,
|
||||||
'case',
|
'line-width': FC_LINE_W_NORMAL,
|
||||||
['==', ['get', 'highlighted'], 1],
|
'line-opacity': 0,
|
||||||
['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
|
|
||||||
['boolean', ['get', 'suspicious'], false],
|
|
||||||
FC_LINE_SUSPICIOUS_ML,
|
|
||||||
FC_LINE_NORMAL_ML,
|
|
||||||
] as never,
|
|
||||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], FC_LINE_W_HL, FC_LINE_W_NORMAL] as never,
|
|
||||||
'line-opacity': 0.9,
|
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('FC lines layer add failed:', e);
|
return; // 다음 poll에서 재시도
|
||||||
}
|
}
|
||||||
} else {
|
reorderGlobeFeatureLayers();
|
||||||
guardedSetVisibility(map, layerId, 'visible');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderGlobeFeatureLayers();
|
paintStateRef.current();
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||||
const stop = onMapStyleReady(map, ensure);
|
const stop = onMapStyleReady(map, ensure);
|
||||||
ensure();
|
ensure();
|
||||||
return () => {
|
return () => { stop(); };
|
||||||
stop();
|
}, [projection, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
};
|
|
||||||
}, [
|
|
||||||
projection,
|
|
||||||
overlays.fcLines,
|
|
||||||
fcLinks,
|
|
||||||
hoveredPairMmsiList,
|
|
||||||
hoveredFleetMmsiList,
|
|
||||||
mapSyncEpoch,
|
|
||||||
reorderGlobeFeatureLayers,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Fleet circles
|
// ── Fleet circles 데이터 effect (stroke only — fill 제거) ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const srcId = 'fleet-circles-ml-src';
|
const srcId = 'fleet-circles-ml-src';
|
||||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
|
||||||
const layerId = 'fleet-circles-ml';
|
const layerId = 'fleet-circles-ml';
|
||||||
const fillLayerId = 'fleet-circles-ml-fill';
|
|
||||||
|
|
||||||
const remove = () => {
|
|
||||||
guardedSetVisibility(map, layerId, 'none');
|
|
||||||
guardedSetVisibility(map, fillLayerId, 'none');
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projection !== 'globe' || (fleetCircles?.length ?? 0) === 0) {
|
||||||
if (!map.isStyleLoaded()) return;
|
try {
|
||||||
const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) {
|
} catch { /* ignore */ }
|
||||||
remove();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const circles = fleetCircles || [];
|
const circles = fleetCircles || [];
|
||||||
const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) =>
|
|
||||||
hoveredFleetOwnerKeyList.includes(ownerKey) ||
|
|
||||||
(hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi)));
|
|
||||||
|
|
||||||
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
@ -205,47 +176,21 @@ export function useGlobeFcFleetOverlay(
|
|||||||
ownerLabel: c.ownerLabel,
|
ownerLabel: c.ownerLabel,
|
||||||
count: c.count,
|
count: c.count,
|
||||||
vesselMmsis: c.vesselMmsis,
|
vesselMmsis: c.vesselMmsis,
|
||||||
highlighted: 0,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: circles
|
|
||||||
.filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis))
|
|
||||||
.map((c) => ({
|
|
||||||
type: 'Feature',
|
|
||||||
id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`),
|
|
||||||
geometry: {
|
|
||||||
type: 'Polygon',
|
|
||||||
coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)],
|
|
||||||
},
|
|
||||||
properties: {
|
|
||||||
ownerKey: c.ownerKey,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
if (existing) existing.setData(fcLine);
|
if (existing) existing.setData(fcLine);
|
||||||
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
|
else map.addSource(srcId, { type: 'geojson', data: fcLine } as GeoJSONSourceSpecification);
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('Fleet circles source setup failed:', e);
|
return; // 다음 poll에서 재시도
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const needReorder = !map.getLayer(layerId);
|
||||||
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
|
if (needReorder) {
|
||||||
if (existingFill) existingFill.setData(fcFill);
|
|
||||||
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Fleet circles fill source setup failed:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!map.getLayer(layerId)) {
|
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -254,66 +199,34 @@ export function useGlobeFcFleetOverlay(
|
|||||||
source: srcId,
|
source: srcId,
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
'line-color': FLEET_LINE_ML,
|
||||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never,
|
'line-width': FLEET_LINE_W_NORMAL,
|
||||||
'line-opacity': 0.85,
|
'line-opacity': 0,
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('Fleet circles layer add failed:', e);
|
return; // 다음 poll에서 재시도
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
guardedSetVisibility(map, layerId, 'visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!map.getLayer(fillLayerId)) {
|
|
||||||
try {
|
|
||||||
map.addLayer(
|
|
||||||
{
|
|
||||||
id: fillLayerId,
|
|
||||||
type: 'fill',
|
|
||||||
source: fillSrcId,
|
|
||||||
layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' },
|
|
||||||
paint: {
|
|
||||||
'fill-color': FLEET_FILL_ML_HL,
|
|
||||||
},
|
|
||||||
} as unknown as LayerSpecification,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Fleet circles fill layer add failed:', e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none');
|
|
||||||
}
|
|
||||||
|
|
||||||
reorderGlobeFeatureLayers();
|
reorderGlobeFeatureLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
paintStateRef.current();
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = onMapStyleReady(map, ensure);
|
const stop = onMapStyleReady(map, ensure);
|
||||||
ensure();
|
ensure();
|
||||||
return () => {
|
return () => { stop(); };
|
||||||
stop();
|
}, [projection, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
};
|
|
||||||
}, [
|
|
||||||
projection,
|
|
||||||
overlays.fleetCircles,
|
|
||||||
fleetCircles,
|
|
||||||
hoveredFleetOwnerKeyList,
|
|
||||||
hoveredFleetMmsiList,
|
|
||||||
mapSyncEpoch,
|
|
||||||
reorderGlobeFeatureLayers,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// FC + Fleet paint state updates
|
// ── FC + Fleet paint state update (가시성 + 하이라이트 통합) ──
|
||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
const updateFcFleetPaintStates = useCallback(() => {
|
const updateFcFleetPaintStates = useCallback(() => {
|
||||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
if (projection !== 'globe') return;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !map.isStyleLoaded()) return;
|
if (!map) return;
|
||||||
|
|
||||||
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||||
|
|
||||||
@ -330,42 +243,68 @@ export function useGlobeFcFleetOverlay(
|
|||||||
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
// ── FC lines ──
|
||||||
|
const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
const fcVisible = overlays.fcLines || pairActive;
|
||||||
|
// ── Fleet circles ──
|
||||||
|
const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
const fleetVisible = overlays.fleetCircles || fleetActive;
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('fc-lines-ml')) {
|
if (map.getLayer('fc-lines-ml')) {
|
||||||
|
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);
|
||||||
|
if (fcVisible) {
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'fc-lines-ml', 'line-color',
|
'fc-lines-ml', 'line-color',
|
||||||
['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
fcEndpointHighlightExpr !== false
|
||||||
|
? ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never
|
||||||
|
: ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never,
|
||||||
);
|
);
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'fc-lines-ml', 'line-width',
|
'fc-lines-ml', 'line-width',
|
||||||
['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never,
|
fcEndpointHighlightExpr !== false
|
||||||
|
? ['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never
|
||||||
|
: FC_LINE_W_NORMAL,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('fleet-circles-ml')) {
|
if (map.getLayer('fleet-circles-ml')) {
|
||||||
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
|
map.setPaintProperty('fleet-circles-ml', 'line-opacity', fleetVisible ? 0.85 : 0);
|
||||||
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never);
|
if (fleetVisible) {
|
||||||
|
map.setPaintProperty('fleet-circles-ml', 'line-color',
|
||||||
|
fleetHighlightExpr !== false
|
||||||
|
? ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never
|
||||||
|
: FLEET_LINE_ML,
|
||||||
|
);
|
||||||
|
map.setPaintProperty('fleet-circles-ml', 'line-width',
|
||||||
|
fleetHighlightExpr !== false
|
||||||
|
? ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never
|
||||||
|
: FLEET_LINE_W_NORMAL,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]);
|
|
||||||
|
|
||||||
|
kickRepaint(map);
|
||||||
|
}, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, overlays.fcLines, overlays.fleetCircles]);
|
||||||
|
|
||||||
|
// paintStateRef를 최신 콜백으로 유지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
paintStateRef.current = updateFcFleetPaintStates;
|
||||||
if (!map) return;
|
}, [updateFcFleetPaintStates]);
|
||||||
const stop = onMapStyleReady(map, updateFcFleetPaintStates);
|
|
||||||
updateFcFleetPaintStates();
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]);
|
|
||||||
|
|
||||||
// Breathing animation for highlighted fc/fleet overlays
|
// paint state 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
updateFcFleetPaintStates();
|
||||||
|
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, overlays.fcLines, overlays.fleetCircles, updateFcFleetPaintStates, fcLinks, fleetCircles]);
|
||||||
|
|
||||||
|
// ── Breathing animation ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
|||||||
@ -14,6 +14,10 @@ import {
|
|||||||
} from '../lib/tooltips';
|
} from '../lib/tooltips';
|
||||||
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
|
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils';
|
||||||
|
|
||||||
|
// setData() 후 타일 재빌드 중 queryRenderedFeatures가 일시적으로 빈 배열을 반환.
|
||||||
|
// 즉시 clear 대신 딜레이를 두어 깜박임 방지.
|
||||||
|
const TOOLTIP_CLEAR_DELAY_MS = 150;
|
||||||
|
|
||||||
export function useGlobeInteraction(
|
export function useGlobeInteraction(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
@ -57,7 +61,7 @@ export function useGlobeInteraction(
|
|||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
|
const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !map.isStyleLoaded()) return;
|
if (!map) return;
|
||||||
if (!mapTooltipRef.current) {
|
if (!mapTooltipRef.current) {
|
||||||
mapTooltipRef.current = new maplibregl.Popup({
|
mapTooltipRef.current = new maplibregl.Popup({
|
||||||
closeButton: false,
|
closeButton: false,
|
||||||
@ -74,6 +78,12 @@ export function useGlobeInteraction(
|
|||||||
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
|
mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// buildGlobeFeatureTooltip을 ref로 관리 — legacyHits/shipByMmsi가 매 AIS poll마다 변경되므로
|
||||||
|
// useCallback 의존성으로 넣으면 effect가 재실행되어 cleanup에서 tooltip이 제거됨
|
||||||
|
// ref로 관리하면 effect 재실행 없이 항상 최신 함수 참조
|
||||||
|
type TooltipFeature = { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined;
|
||||||
|
const buildTooltipRef = useRef<(feature: TooltipFeature) => { html: string } | null>(() => null);
|
||||||
|
|
||||||
const buildGlobeFeatureTooltip = useCallback(
|
const buildGlobeFeatureTooltip = useCallback(
|
||||||
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
|
(feature: { properties?: Record<string, unknown> | null; layer?: { id?: string } } | null | undefined) => {
|
||||||
if (!feature) return null;
|
if (!feature) return null;
|
||||||
@ -136,17 +146,14 @@ export function useGlobeInteraction(
|
|||||||
[legacyHits, shipByMmsi],
|
[legacyHits, shipByMmsi],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
buildTooltipRef.current = buildGlobeFeatureTooltip;
|
||||||
|
}, [buildGlobeFeatureTooltip]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const clearDeckGlobeHoverState = () => {
|
|
||||||
clearDeckHoverMmsi();
|
|
||||||
clearDeckHoverPairs();
|
|
||||||
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
|
||||||
clearMapFleetHoverState();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetGlobeHoverStates = () => {
|
const resetGlobeHoverStates = () => {
|
||||||
clearDeckHoverMmsi();
|
clearDeckHoverMmsi();
|
||||||
clearDeckHoverPairs();
|
clearDeckHoverPairs();
|
||||||
@ -155,36 +162,52 @@ export function useGlobeInteraction(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeMmsiList = (value: unknown): number[] => {
|
const normalizeMmsiList = (value: unknown): number[] => {
|
||||||
if (!Array.isArray(value)) return [];
|
let arr = value;
|
||||||
|
// MapLibre는 GeoJSON 배열 프로퍼티를 JSON 문자열로 반환할 수 있음
|
||||||
|
if (typeof arr === 'string') {
|
||||||
|
try { arr = JSON.parse(arr); } catch { return []; }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
const out: number[] = [];
|
const out: number[] = [];
|
||||||
for (const n of value) {
|
for (const n of arr) {
|
||||||
const m = toIntMmsi(n);
|
const m = toIntMmsi(n);
|
||||||
if (m != null) out.push(m);
|
if (m != null) out.push(m);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 지연 clear 타이머 — setData() 타일 재빌드 중 일시적 빈 결과를 무시
|
||||||
|
let clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const scheduleClear = () => {
|
||||||
|
if (clearTimer) return; // 이미 예약됨
|
||||||
|
clearTimer = setTimeout(() => {
|
||||||
|
clearTimer = null;
|
||||||
|
resetGlobeHoverStates();
|
||||||
|
clearGlobeTooltip();
|
||||||
|
}, TOOLTIP_CLEAR_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelClear = () => {
|
||||||
|
if (clearTimer) { clearTimeout(clearTimer); clearTimer = null; }
|
||||||
|
};
|
||||||
|
|
||||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
if (projection !== 'globe') {
|
if (projection !== 'globe') {
|
||||||
|
cancelClear();
|
||||||
clearGlobeTooltip();
|
clearGlobeTooltip();
|
||||||
resetGlobeHoverStates();
|
resetGlobeHoverStates();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (projectionBusyRef.current) {
|
if (projectionBusyRef.current) {
|
||||||
resetGlobeHoverStates();
|
return; // 전환 중에는 기존 상태 유지 (clear하면 깜박임)
|
||||||
clearGlobeTooltip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!map.isStyleLoaded()) {
|
|
||||||
clearDeckGlobeHoverState();
|
|
||||||
clearGlobeTooltip();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidateLayerIds: string[] = [];
|
let candidateLayerIds: string[] = [];
|
||||||
try {
|
try {
|
||||||
candidateLayerIds = [
|
candidateLayerIds = [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
|
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||||
'pair-lines-ml', 'fc-lines-ml',
|
'pair-lines-ml', 'fc-lines-ml',
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
'pair-range-ml',
|
'pair-range-ml',
|
||||||
@ -195,14 +218,18 @@ export function useGlobeInteraction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (candidateLayerIds.length === 0) {
|
if (candidateLayerIds.length === 0) {
|
||||||
resetGlobeHoverStates();
|
scheduleClear();
|
||||||
clearGlobeTooltip();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
|
let rendered: Array<{ properties?: Record<string, unknown> | null; layer?: { id?: string } }> = [];
|
||||||
try {
|
try {
|
||||||
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
|
const tolerance = 10;
|
||||||
|
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||||
|
[e.point.x - tolerance, e.point.y - tolerance],
|
||||||
|
[e.point.x + tolerance, e.point.y + tolerance],
|
||||||
|
];
|
||||||
|
rendered = map.queryRenderedFeatures(bbox, { layers: candidateLayerIds }) as unknown as Array<{
|
||||||
properties?: Record<string, unknown> | null;
|
properties?: Record<string, unknown> | null;
|
||||||
layer?: { id?: string };
|
layer?: { id?: string };
|
||||||
}>;
|
}>;
|
||||||
@ -212,6 +239,7 @@ export function useGlobeInteraction(
|
|||||||
|
|
||||||
const priority = [
|
const priority = [
|
||||||
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-lite', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
|
'ships-globe-alarm-pulse', 'ships-globe-alarm-badge',
|
||||||
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
'zones-fill', 'zones-line', 'zones-label',
|
'zones-fill', 'zones-line', 'zones-label',
|
||||||
@ -222,18 +250,23 @@ export function useGlobeInteraction(
|
|||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
if (!first) {
|
if (!first) {
|
||||||
resetGlobeHoverStates();
|
// 피처 없음 — 타일 재빌드 중 일시적 누락일 수 있으므로 지연 clear
|
||||||
clearGlobeTooltip();
|
scheduleClear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 피처 발견 — 지연 clear 취소
|
||||||
|
cancelClear();
|
||||||
|
|
||||||
const layerId = first.layer?.id;
|
const layerId = first.layer?.id;
|
||||||
const props = first.properties || {};
|
const props = first.properties || {};
|
||||||
const isShipLayer =
|
const isShipLayer =
|
||||||
layerId === 'ships-globe' ||
|
layerId === 'ships-globe' ||
|
||||||
layerId === 'ships-globe-lite' ||
|
layerId === 'ships-globe-lite' ||
|
||||||
layerId === 'ships-globe-halo' ||
|
layerId === 'ships-globe-halo' ||
|
||||||
layerId === 'ships-globe-outline';
|
layerId === 'ships-globe-outline' ||
|
||||||
|
layerId === 'ships-globe-alarm-pulse' ||
|
||||||
|
layerId === 'ships-globe-alarm-badge';
|
||||||
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||||
const isFcLayer = layerId === 'fc-lines-ml';
|
const isFcLayer = layerId === 'fc-lines-ml';
|
||||||
const isFleetLayer = layerId === 'fleet-circles-ml';
|
const isFleetLayer = layerId === 'fleet-circles-ml';
|
||||||
@ -277,7 +310,7 @@ export function useGlobeInteraction(
|
|||||||
resetGlobeHoverStates();
|
resetGlobeHoverStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltip = buildGlobeFeatureTooltip(first);
|
const tooltip = buildTooltipRef.current(first);
|
||||||
if (!tooltip) {
|
if (!tooltip) {
|
||||||
if (!isZoneLayer) {
|
if (!isZoneLayer) {
|
||||||
resetGlobeHoverStates();
|
resetGlobeHoverStates();
|
||||||
@ -295,6 +328,7 @@ export function useGlobeInteraction(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onMouseOut = () => {
|
const onMouseOut = () => {
|
||||||
|
cancelClear();
|
||||||
resetGlobeHoverStates();
|
resetGlobeHoverStates();
|
||||||
clearGlobeTooltip();
|
clearGlobeTooltip();
|
||||||
};
|
};
|
||||||
@ -303,13 +337,14 @@ export function useGlobeInteraction(
|
|||||||
map.on('mouseout', onMouseOut);
|
map.on('mouseout', onMouseOut);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelClear();
|
||||||
map.off('mousemove', onMouseMove);
|
map.off('mousemove', onMouseMove);
|
||||||
map.off('mouseout', onMouseOut);
|
map.off('mouseout', onMouseOut);
|
||||||
clearGlobeTooltip();
|
// cleanup에서 tooltip 제거하지 않음 — 의존성 변경(AIS poll 등)으로 effect가
|
||||||
|
// 재실행될 때 tooltip이 사라지는 문제 방지. tooltip은 mousemove/mouseout 이벤트가 처리.
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
projection,
|
projection,
|
||||||
buildGlobeFeatureTooltip,
|
|
||||||
clearGlobeTooltip,
|
clearGlobeTooltip,
|
||||||
clearMapFleetHoverState,
|
clearMapFleetHoverState,
|
||||||
clearDeckHoverPairs,
|
clearDeckHoverPairs,
|
||||||
@ -319,4 +354,9 @@ export function useGlobeInteraction(
|
|||||||
setMapFleetHoverState,
|
setMapFleetHoverState,
|
||||||
setGlobeTooltip,
|
setGlobeTooltip,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 컴포넌트 unmount 시에만 tooltip 제거
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { clearGlobeTooltip(); };
|
||||||
|
}, [clearGlobeTooltip]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import { makePairLinkFeatureId } from '../lib/featureIds';
|
|||||||
import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions';
|
import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
import { circleRingLngLat } from '../lib/geometry';
|
import { circleRingLngLat } from '../lib/geometry';
|
||||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
|
||||||
|
|
||||||
// ── Overlay line width constants ──
|
// ── Overlay line width constants ──
|
||||||
const PAIR_LINE_W_NORMAL = 2.5;
|
const PAIR_LINE_W_NORMAL = 2.5;
|
||||||
@ -30,7 +29,7 @@ const BREATHE_PERIOD_MS = 1200;
|
|||||||
/** Globe pair lines + pair range 오버레이 */
|
/** Globe pair lines + pair range 오버레이 */
|
||||||
export function useGlobePairOverlay(
|
export function useGlobePairOverlay(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
_projectionBusyRef: MutableRefObject<boolean>,
|
||||||
reorderGlobeFeatureLayers: () => void,
|
reorderGlobeFeatureLayers: () => void,
|
||||||
opts: {
|
opts: {
|
||||||
overlays: MapToggleState;
|
overlays: MapToggleState;
|
||||||
@ -43,7 +42,12 @@ export function useGlobePairOverlay(
|
|||||||
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
|
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
|
||||||
const breatheRafRef = useRef<number>(0);
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
|
||||||
// Pair lines
|
// paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용
|
||||||
|
const paintStateRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// ── Pair lines 데이터 effect ──
|
||||||
|
// projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리
|
||||||
|
// 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -51,16 +55,17 @@ export function useGlobePairOverlay(
|
|||||||
const srcId = 'pair-lines-ml-src';
|
const srcId = 'pair-lines-ml-src';
|
||||||
const layerId = 'pair-lines-ml';
|
const layerId = 'pair-lines-ml';
|
||||||
|
|
||||||
const remove = () => {
|
|
||||||
guardedSetVisibility(map, layerId, 'none');
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projection !== 'globe') {
|
||||||
if (!map.isStyleLoaded()) return;
|
try {
|
||||||
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
if (projection !== 'globe' || (!overlays.pairLines && !pairHoverActive) || (pairLinks?.length ?? 0) === 0) {
|
} catch { /* ignore */ }
|
||||||
remove();
|
return;
|
||||||
|
}
|
||||||
|
if ((pairLinks?.length ?? 0) === 0) {
|
||||||
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,12 +89,12 @@ export function useGlobePairOverlay(
|
|||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
if (existing) existing.setData(fc);
|
if (existing) existing.setData(fc);
|
||||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('Pair lines source setup failed:', e);
|
return; // 다음 poll에서 재시도
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getLayer(layerId)) {
|
const needReorder = !map.getLayer(layerId);
|
||||||
|
if (needReorder) {
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -98,44 +103,31 @@ export function useGlobePairOverlay(
|
|||||||
source: srcId,
|
source: srcId,
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': [
|
'line-color': PAIR_LINE_NORMAL_ML,
|
||||||
'case',
|
'line-width': PAIR_LINE_W_NORMAL,
|
||||||
['==', ['get', 'highlighted'], 1],
|
'line-opacity': 0,
|
||||||
['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL],
|
|
||||||
['boolean', ['get', 'warn'], false],
|
|
||||||
PAIR_LINE_WARN_ML,
|
|
||||||
PAIR_LINE_NORMAL_ML,
|
|
||||||
] as never,
|
|
||||||
'line-width': [
|
|
||||||
'case',
|
|
||||||
['==', ['get', 'highlighted'], 1], PAIR_LINE_W_HL,
|
|
||||||
['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN,
|
|
||||||
PAIR_LINE_W_NORMAL,
|
|
||||||
] as never,
|
|
||||||
'line-opacity': 0.9,
|
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('Pair lines layer add failed:', e);
|
return; // 다음 poll에서 재시도
|
||||||
}
|
}
|
||||||
} else {
|
reorderGlobeFeatureLayers();
|
||||||
guardedSetVisibility(map, layerId, 'visible');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderGlobeFeatureLayers();
|
// 즉시 올바른 paint state 적용 — 타이밍 간극으로 opacity:0 고착 방지
|
||||||
|
paintStateRef.current();
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||||
const stop = onMapStyleReady(map, ensure);
|
const stop = onMapStyleReady(map, ensure);
|
||||||
ensure();
|
ensure();
|
||||||
return () => {
|
return () => { stop(); };
|
||||||
stop();
|
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
};
|
|
||||||
}, [projection, overlays.pairLines, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
|
||||||
|
|
||||||
// Pair range
|
// ── Pair range 데이터 effect ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -143,16 +135,11 @@ export function useGlobePairOverlay(
|
|||||||
const srcId = 'pair-range-ml-src';
|
const srcId = 'pair-range-ml-src';
|
||||||
const layerId = 'pair-range-ml';
|
const layerId = 'pair-range-ml';
|
||||||
|
|
||||||
const remove = () => {
|
|
||||||
guardedSetVisibility(map, layerId, 'none');
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
if (projection !== 'globe') {
|
||||||
if (!map.isStyleLoaded()) return;
|
try {
|
||||||
const pairHoverActive = hoveredPairMmsiList.length >= 2;
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
if (projection !== 'globe' || (!overlays.pairRange && !pairHoverActive)) {
|
} catch { /* ignore */ }
|
||||||
remove();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,7 +156,9 @@ export function useGlobePairOverlay(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (ranges.length === 0) {
|
if (ranges.length === 0) {
|
||||||
remove();
|
try {
|
||||||
|
if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0);
|
||||||
|
} catch { /* ignore */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,7 +176,6 @@ export function useGlobePairOverlay(
|
|||||||
aMmsi: c.aMmsi,
|
aMmsi: c.aMmsi,
|
||||||
bMmsi: c.bMmsi,
|
bMmsi: c.bMmsi,
|
||||||
distanceNm: c.distanceNm,
|
distanceNm: c.distanceNm,
|
||||||
highlighted: 0,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@ -197,12 +185,12 @@ export function useGlobePairOverlay(
|
|||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
if (existing) existing.setData(fc);
|
if (existing) existing.setData(fc);
|
||||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('Pair range source setup failed:', e);
|
return; // 다음 poll에서 재시도
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getLayer(layerId)) {
|
const needReorder = !map.getLayer(layerId);
|
||||||
|
if (needReorder) {
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -215,90 +203,105 @@ export function useGlobePairOverlay(
|
|||||||
visibility: 'visible',
|
visibility: 'visible',
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': [
|
'line-color': PAIR_RANGE_NORMAL_ML,
|
||||||
'case',
|
'line-width': PAIR_RANGE_W_NORMAL,
|
||||||
['==', ['get', 'highlighted'], 1],
|
'line-opacity': 0,
|
||||||
['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
|
|
||||||
['boolean', ['get', 'warn'], false],
|
|
||||||
PAIR_RANGE_WARN_ML,
|
|
||||||
PAIR_RANGE_NORMAL_ML,
|
|
||||||
] as never,
|
|
||||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never,
|
|
||||||
'line-opacity': 0.85,
|
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('Pair range layer add failed:', e);
|
return; // 다음 poll에서 재시도
|
||||||
}
|
}
|
||||||
} else {
|
reorderGlobeFeatureLayers();
|
||||||
guardedSetVisibility(map, layerId, 'visible');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
paintStateRef.current();
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장
|
||||||
const stop = onMapStyleReady(map, ensure);
|
const stop = onMapStyleReady(map, ensure);
|
||||||
ensure();
|
ensure();
|
||||||
return () => {
|
return () => { stop(); };
|
||||||
stop();
|
}, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
};
|
|
||||||
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
|
||||||
|
|
||||||
// Pair paint state updates + breathing animation
|
// ── Pair paint state update (가시성 + 하이라이트 통합) ──
|
||||||
|
// setLayoutProperty(visibility) 대신 setPaintProperty(line-opacity)로 가시성 제어
|
||||||
|
// → style._changed 미트리거 → alarm badge symbol placement 재계산 방지
|
||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
const updatePairPaintStates = useCallback(() => {
|
const updatePairPaintStates = useCallback(() => {
|
||||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
if (projection !== 'globe') return;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !map.isStyleLoaded()) return;
|
if (!map) return;
|
||||||
|
|
||||||
const pairHighlightExpr = hoveredPairMmsiList.length >= 2
|
const active = hoveredPairMmsiList.length >= 2;
|
||||||
|
const pairHighlightExpr = active
|
||||||
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
// ── Pair lines: 가시성 + 하이라이트 ──
|
||||||
|
const pairLinesVisible = overlays.pairLines || active;
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('pair-lines-ml')) {
|
if (map.getLayer('pair-lines-ml')) {
|
||||||
|
map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0);
|
||||||
|
if (pairLinesVisible) {
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'pair-lines-ml', 'line-color',
|
'pair-lines-ml', 'line-color',
|
||||||
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
pairHighlightExpr !== false
|
||||||
|
? ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never
|
||||||
|
: ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never,
|
||||||
);
|
);
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'pair-lines-ml', 'line-width',
|
'pair-lines-ml', 'line-width',
|
||||||
['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never,
|
pairHighlightExpr !== false
|
||||||
|
? ['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never
|
||||||
|
: ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pair range: 가시성 + 하이라이트 ──
|
||||||
|
const pairRangeVisible = overlays.pairRange || active;
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('pair-range-ml')) {
|
if (map.getLayer('pair-range-ml')) {
|
||||||
|
map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0);
|
||||||
|
if (pairRangeVisible) {
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'pair-range-ml', 'line-color',
|
'pair-range-ml', 'line-color',
|
||||||
['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
pairHighlightExpr !== false
|
||||||
|
? ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never
|
||||||
|
: ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never,
|
||||||
);
|
);
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'pair-range-ml', 'line-width',
|
'pair-range-ml', 'line-width',
|
||||||
['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never,
|
pairHighlightExpr !== false
|
||||||
|
? ['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never
|
||||||
|
: PAIR_RANGE_W_NORMAL,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [projection, hoveredPairMmsiList]);
|
|
||||||
|
|
||||||
|
kickRepaint(map);
|
||||||
|
}, [projection, hoveredPairMmsiList, overlays.pairLines, overlays.pairRange]);
|
||||||
|
|
||||||
|
// paintStateRef를 최신 콜백으로 유지 — useEffect 내에서만 ref 업데이트 (react-hooks/refs 준수)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
paintStateRef.current = updatePairPaintStates;
|
||||||
if (!map) return;
|
}, [updatePairPaintStates]);
|
||||||
const stop = onMapStyleReady(map, updatePairPaintStates);
|
|
||||||
updatePairPaintStates();
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
|
|
||||||
|
|
||||||
// Breathing animation for highlighted pair overlays
|
// paint state 동기화: 호버/토글/epoch 변경 시 즉시 반영
|
||||||
|
useEffect(() => {
|
||||||
|
updatePairPaintStates();
|
||||||
|
}, [mapSyncEpoch, hoveredPairMmsiList, projection, overlays.pairLines, overlays.pairRange, updatePairPaintStates, pairLinks]);
|
||||||
|
|
||||||
|
// ── Breathing animation for highlighted pair overlays ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') {
|
if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
DEG2RAD,
|
DEG2RAD,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { isFiniteNumber } from '../lib/setUtils';
|
import { isFiniteNumber } from '../lib/setUtils';
|
||||||
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
|
import { GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR } from '../lib/mlExpressions';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils';
|
import { getDisplayHeading, getGlobeBaseShipColor } from '../lib/shipUtils';
|
||||||
import { ensureFallbackShipImage } from '../lib/globeShipIcon';
|
import { ensureFallbackShipImage } from '../lib/globeShipIcon';
|
||||||
@ -19,7 +19,7 @@ import { guardedSetVisibility } from '../lib/layerHelpers';
|
|||||||
/** Globe 호버 오버레이 + 클릭 선택 */
|
/** Globe 호버 오버레이 + 클릭 선택 */
|
||||||
export function useGlobeShipHover(
|
export function useGlobeShipHover(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
_projectionBusyRef: MutableRefObject<boolean>,
|
||||||
reorderGlobeFeatureLayers: () => void,
|
reorderGlobeFeatureLayers: () => void,
|
||||||
opts: {
|
opts: {
|
||||||
projection: MapProjectionId;
|
projection: MapProjectionId;
|
||||||
@ -62,9 +62,6 @@ export function useGlobeShipHover(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
|
||||||
if (!map.isStyleLoaded()) return;
|
|
||||||
|
|
||||||
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
||||||
hideHover();
|
hideHover();
|
||||||
return;
|
return;
|
||||||
@ -74,7 +71,9 @@ export function useGlobeShipHover(
|
|||||||
epochRef.current = mapSyncEpoch;
|
epochRef.current = mapSyncEpoch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
ensureFallbackShipImage(map, imgId);
|
ensureFallbackShipImage(map, imgId);
|
||||||
|
} catch { /* ignore */ }
|
||||||
if (!map.hasImage(imgId)) {
|
if (!map.hasImage(imgId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -166,7 +165,7 @@ export function useGlobeShipHover(
|
|||||||
] as never,
|
] as never,
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
|
||||||
'circle-color': [
|
'circle-color': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
|
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
|
||||||
@ -181,7 +180,7 @@ export function useGlobeShipHover(
|
|||||||
console.warn('Ship hover halo layer add failed:', e);
|
console.warn('Ship hover halo layer add failed:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
map.setLayoutProperty(haloId, 'visibility', 'visible');
|
guardedSetVisibility(map, haloId, 'visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getLayer(outlineId)) {
|
if (!map.getLayer(outlineId)) {
|
||||||
@ -192,7 +191,7 @@ export function useGlobeShipHover(
|
|||||||
type: 'circle',
|
type: 'circle',
|
||||||
source: srcId,
|
source: srcId,
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR,
|
||||||
'circle-color': 'rgba(0,0,0,0)',
|
'circle-color': 'rgba(0,0,0,0)',
|
||||||
'circle-stroke-color': [
|
'circle-stroke-color': [
|
||||||
'case',
|
'case',
|
||||||
@ -222,7 +221,7 @@ export function useGlobeShipHover(
|
|||||||
console.warn('Ship hover outline layer add failed:', e);
|
console.warn('Ship hover outline layer add failed:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
map.setLayoutProperty(outlineId, 'visibility', 'visible');
|
guardedSetVisibility(map, outlineId, 'visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getLayer(symbolId)) {
|
if (!map.getLayer(symbolId)) {
|
||||||
@ -267,7 +266,7 @@ export function useGlobeShipHover(
|
|||||||
console.warn('Ship hover symbol layer add failed:', e);
|
console.warn('Ship hover symbol layer add failed:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
map.setLayoutProperty(symbolId, 'visibility', 'visible');
|
guardedSetVisibility(map, symbolId, 'visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needReorder) {
|
if (needReorder) {
|
||||||
@ -301,15 +300,20 @@ export function useGlobeShipHover(
|
|||||||
const symbolLiteId = 'ships-globe-lite';
|
const symbolLiteId = 'ships-globe-lite';
|
||||||
const haloId = 'ships-globe-halo';
|
const haloId = 'ships-globe-halo';
|
||||||
const outlineId = 'ships-globe-outline';
|
const outlineId = 'ships-globe-outline';
|
||||||
const clickedRadiusDeg2 = Math.pow(0.08, 2);
|
const clickedRadiusDeg2 = Math.pow(0.12, 2);
|
||||||
|
|
||||||
const onClick = (e: maplibregl.MapMouseEvent) => {
|
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||||
try {
|
try {
|
||||||
const layerIds = [symbolId, symbolLiteId, haloId, outlineId].filter((id) => map.getLayer(id));
|
const layerIds = [symbolId, symbolLiteId, haloId, outlineId, 'ships-globe-alarm-pulse', 'ships-globe-alarm-badge'].filter((id) => map.getLayer(id));
|
||||||
let feats: unknown[] = [];
|
let feats: unknown[] = [];
|
||||||
if (layerIds.length > 0) {
|
if (layerIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[];
|
const tolerance = 10;
|
||||||
|
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||||
|
[e.point.x - tolerance, e.point.y - tolerance],
|
||||||
|
[e.point.x + tolerance, e.point.y + tolerance],
|
||||||
|
];
|
||||||
|
feats = map.queryRenderedFeatures(bbox, { layers: layerIds }) as unknown[];
|
||||||
} catch {
|
} catch {
|
||||||
feats = [];
|
feats = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,6 +60,8 @@ export function useGlobeShipLayers(
|
|||||||
|
|
||||||
const epochRef = useRef(-1);
|
const epochRef = useRef(-1);
|
||||||
const breatheRafRef = useRef<number>(0);
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
const prevGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
||||||
|
const prevAlarmGeoJsonRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
||||||
|
|
||||||
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
||||||
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
||||||
@ -83,16 +85,13 @@ export function useGlobeShipLayers(
|
|||||||
50, 420,
|
50, 420,
|
||||||
);
|
);
|
||||||
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85);
|
||||||
const selected = t.mmsi === selectedMmsi;
|
// 상호작용 스케일링 제거 — selected/highlighted는 feature-state로 처리
|
||||||
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
// hover overlay 레이어가 확대 + z-priority를 담당
|
||||||
const selectedScale = selected ? 1.08 : 1;
|
const iconSize3 = clampNumber(0.35 * sizeScale, 0.25, 1.3);
|
||||||
const highlightScale = highlighted ? 1.06 : 1;
|
const iconSize7 = clampNumber(0.45 * sizeScale, 0.3, 1.45);
|
||||||
const iconScale = selected ? selectedScale : highlightScale;
|
const iconSize10 = clampNumber(0.58 * sizeScale, 0.35, 1.8);
|
||||||
const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3);
|
const iconSize14 = clampNumber(0.85 * sizeScale, 0.45, 2.6);
|
||||||
const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45);
|
const iconSize18 = clampNumber(2.5 * sizeScale, 1.0, 6.0);
|
||||||
const iconSize10 = clampNumber(0.58 * sizeScale * selectedScale, 0.35, 1.8);
|
|
||||||
const iconSize14 = clampNumber(0.85 * sizeScale * selectedScale, 0.45, 2.6);
|
|
||||||
const iconSize18 = clampNumber(2.5 * sizeScale * selectedScale, 1.0, 6.0);
|
|
||||||
return {
|
return {
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||||
@ -109,25 +108,24 @@ export function useGlobeShipLayers(
|
|||||||
legacy: legacy?.shipCode || null,
|
legacy: legacy?.shipCode || null,
|
||||||
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
sog: isFiniteNumber(t.sog) ? t.sog : null,
|
||||||
}),
|
}),
|
||||||
iconSize3: iconSize3 * iconScale,
|
iconSize3,
|
||||||
iconSize7: iconSize7 * iconScale,
|
iconSize7,
|
||||||
iconSize10: iconSize10 * iconScale,
|
iconSize10,
|
||||||
iconSize14: iconSize14 * iconScale,
|
iconSize14,
|
||||||
iconSize18: iconSize18 * iconScale,
|
iconSize18,
|
||||||
sizeScale,
|
sizeScale,
|
||||||
selected: selected ? 1 : 0,
|
|
||||||
highlighted: highlighted ? 1 : 0,
|
|
||||||
permitted: legacy ? 1 : 0,
|
permitted: legacy ? 1 : 0,
|
||||||
code: legacy?.shipCode || '',
|
code: legacy?.shipCode || '',
|
||||||
alarmed: alarmKind ? 1 : 0,
|
alarmed: alarmKind ? 1 : 0,
|
||||||
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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi, alarmMmsiMap]);
|
}, [shipData, legacyHits, alarmMmsiMap]);
|
||||||
|
|
||||||
// Alarm-only GeoJSON — separate source to avoid badge symbol re-placement
|
// Alarm-only GeoJSON — separate source to avoid badge symbol re-placement
|
||||||
// when the main ship source updates (position polling)
|
// when the main ship source updates (position polling)
|
||||||
@ -141,23 +139,20 @@ export function useGlobeShipLayers(
|
|||||||
.filter((t) => alarmMmsiMap.has(t.mmsi))
|
.filter((t) => alarmMmsiMap.has(t.mmsi))
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
const alarmKind = alarmMmsiMap.get(t.mmsi)!;
|
const alarmKind = alarmMmsiMap.get(t.mmsi)!;
|
||||||
const selected = t.mmsi === selectedMmsi;
|
|
||||||
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
|
||||||
return {
|
return {
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
|
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
||||||
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
||||||
properties: {
|
properties: {
|
||||||
mmsi: t.mmsi,
|
mmsi: t.mmsi,
|
||||||
alarmed: 1,
|
alarmed: 1,
|
||||||
alarmBadgeLabel: ALARM_BADGE[alarmKind].label,
|
alarmBadgeLabel: ALARM_BADGE[alarmKind].label,
|
||||||
alarmBadgeColor: ALARM_BADGE[alarmKind].color,
|
alarmBadgeColor: ALARM_BADGE[alarmKind].color,
|
||||||
selected: selected ? 1 : 0,
|
|
||||||
highlighted: highlighted ? 1 : 0,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, [shipData, alarmMmsiMap, selectedMmsi, isBaseHighlightedMmsi]);
|
}, [shipData, alarmMmsiMap]);
|
||||||
|
|
||||||
// Ships in globe mode
|
// Ships in globe mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -173,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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -203,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 ||
|
||||||
@ -214,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 중에는 차단
|
||||||
@ -235,12 +233,18 @@ export function useGlobeShipLayers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
// 사전 계산된 GeoJSON 사용 (useMemo에서 projection과 무관하게 항상 준비됨)
|
||||||
|
// 참조 동일성 기반 setData 스킵 — 위치 변경 없는 epoch/설정 변경 시 재전송 방지
|
||||||
const geojson = globeShipGeoJson;
|
const geojson = globeShipGeoJson;
|
||||||
|
const geoJsonChanged = geojson !== prevGeoJsonRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
if (existing) existing.setData(geojson);
|
if (existing) {
|
||||||
else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
if (geoJsonChanged) existing.setData(geojson);
|
||||||
|
} else {
|
||||||
|
map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification);
|
||||||
|
}
|
||||||
|
prevGeoJsonRef.current = geojson;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Ship source setup failed:', e);
|
console.warn('Ship source setup failed:', e);
|
||||||
return;
|
return;
|
||||||
@ -249,27 +253,32 @@ export function useGlobeShipLayers(
|
|||||||
// Alarm source — isolated from main source for stable badge rendering
|
// Alarm source — isolated from main source for stable badge rendering
|
||||||
try {
|
try {
|
||||||
const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined;
|
const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined;
|
||||||
if (existingAlarm) existingAlarm.setData(alarmGeoJson);
|
const alarmChanged = alarmGeoJson !== prevAlarmGeoJsonRef.current;
|
||||||
else map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
|
if (existingAlarm) {
|
||||||
|
if (alarmChanged) existingAlarm.setData(alarmGeoJson);
|
||||||
|
} else {
|
||||||
|
map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
|
||||||
|
}
|
||||||
|
prevAlarmGeoJsonRef.current = alarmGeoJson;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Alarm source setup failed:', e);
|
console.warn('Alarm source setup failed:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const before = undefined;
|
const before = undefined;
|
||||||
|
let needReorder = false;
|
||||||
const priorityFilter = [
|
const priorityFilter = [
|
||||||
'any',
|
'any',
|
||||||
['==', ['to-number', ['get', 'permitted'], 0], 1],
|
['==', ['to-number', ['get', 'permitted'], 0], 1],
|
||||||
['==', ['to-number', ['get', 'selected'], 0], 1],
|
['==', ['to-number', ['get', 'alarmed'], 0], 1],
|
||||||
['==', ['to-number', ['get', 'highlighted'], 0], 1],
|
|
||||||
] as unknown as unknown[];
|
] as unknown as unknown[];
|
||||||
const nonPriorityFilter = [
|
const nonPriorityFilter = [
|
||||||
'all',
|
'all',
|
||||||
['==', ['to-number', ['get', 'permitted'], 0], 0],
|
['==', ['to-number', ['get', 'permitted'], 0], 0],
|
||||||
['==', ['to-number', ['get', 'selected'], 0], 0],
|
['==', ['to-number', ['get', 'alarmed'], 0], 0],
|
||||||
['==', ['to-number', ['get', 'highlighted'], 0], 0],
|
|
||||||
] as unknown as unknown[];
|
] as unknown as unknown[];
|
||||||
|
|
||||||
if (!map.getLayer(haloId)) {
|
if (!map.getLayer(haloId)) {
|
||||||
|
needReorder = true;
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -280,12 +289,8 @@ export function useGlobeShipLayers(
|
|||||||
visibility,
|
visibility,
|
||||||
'circle-sort-key': [
|
'circle-sort-key': [
|
||||||
'case',
|
'case',
|
||||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
|
|
||||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
|
|
||||||
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
|
||||||
['==', ['get', 'permitted'], 1], 110,
|
['==', ['get', 'permitted'], 1], 110,
|
||||||
['==', ['get', 'selected'], 1], 60,
|
|
||||||
['==', ['get', 'highlighted'], 1], 55,
|
|
||||||
['==', ['get', 'alarmed'], 1], 22,
|
['==', ['get', 'alarmed'], 1], 22,
|
||||||
20,
|
20,
|
||||||
] as never,
|
] as never,
|
||||||
@ -295,9 +300,10 @@ export function useGlobeShipLayers(
|
|||||||
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
'circle-opacity': [
|
'circle-opacity': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'selected'], 1], 0.38,
|
['==', ['feature-state', 'selected'], 1], 0.38,
|
||||||
['==', ['get', '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,
|
||||||
@ -309,6 +315,7 @@ export function useGlobeShipLayers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getLayer(outlineId)) {
|
if (!map.getLayer(outlineId)) {
|
||||||
|
needReorder = true;
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -320,17 +327,17 @@ export function useGlobeShipLayers(
|
|||||||
'circle-color': 'rgba(0,0,0,0)',
|
'circle-color': 'rgba(0,0,0,0)',
|
||||||
'circle-stroke-color': [
|
'circle-stroke-color': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||||
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||||
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
|
||||||
GLOBE_OUTLINE_OTHER,
|
GLOBE_OUTLINE_OTHER,
|
||||||
] as never,
|
] as never,
|
||||||
'circle-stroke-width': [
|
'circle-stroke-width': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'selected'], 1], 3.4,
|
['==', ['feature-state', 'selected'], 1], 3.4,
|
||||||
['==', ['get', '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,
|
||||||
},
|
},
|
||||||
@ -338,12 +345,8 @@ export function useGlobeShipLayers(
|
|||||||
visibility,
|
visibility,
|
||||||
'circle-sort-key': [
|
'circle-sort-key': [
|
||||||
'case',
|
'case',
|
||||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
|
|
||||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
|
|
||||||
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
|
||||||
['==', ['get', 'permitted'], 1], 120,
|
['==', ['get', 'permitted'], 1], 120,
|
||||||
['==', ['get', 'selected'], 1], 70,
|
|
||||||
['==', ['get', 'highlighted'], 1], 65,
|
|
||||||
['==', ['get', 'alarmed'], 1], 32,
|
['==', ['get', 'alarmed'], 1], 32,
|
||||||
30,
|
30,
|
||||||
] as never,
|
] as never,
|
||||||
@ -359,6 +362,7 @@ export function useGlobeShipLayers(
|
|||||||
// Alarm pulse circle (above outline, below ship icons)
|
// Alarm pulse circle (above outline, below ship icons)
|
||||||
// Uses separate alarm source for stable rendering
|
// Uses separate alarm source for stable rendering
|
||||||
if (!map.getLayer(pulseId)) {
|
if (!map.getLayer(pulseId)) {
|
||||||
|
needReorder = true;
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -382,6 +386,7 @@ export function useGlobeShipLayers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getLayer(symbolLiteId)) {
|
if (!map.getLayer(symbolLiteId)) {
|
||||||
|
needReorder = true;
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -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,
|
||||||
@ -451,6 +456,7 @@ export function useGlobeShipLayers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getLayer(symbolId)) {
|
if (!map.getLayer(symbolId)) {
|
||||||
|
needReorder = true;
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -462,12 +468,8 @@ export function useGlobeShipLayers(
|
|||||||
visibility,
|
visibility,
|
||||||
'symbol-sort-key': [
|
'symbol-sort-key': [
|
||||||
'case',
|
'case',
|
||||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
|
|
||||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
|
|
||||||
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
|
||||||
['==', ['get', 'permitted'], 1], 130,
|
['==', ['get', 'permitted'], 1], 130,
|
||||||
['==', ['get', 'selected'], 1], 80,
|
|
||||||
['==', ['get', 'highlighted'], 1], 75,
|
|
||||||
['==', ['get', 'alarmed'], 1], 47,
|
['==', ['get', 'alarmed'], 1], 47,
|
||||||
45,
|
45,
|
||||||
] as never,
|
] as never,
|
||||||
@ -500,8 +502,8 @@ export function useGlobeShipLayers(
|
|||||||
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
|
||||||
'icon-opacity': [
|
'icon-opacity': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'selected'], 1], 1,
|
['==', ['feature-state', 'selected'], 1], 1,
|
||||||
['==', ['get', 'highlighted'], 1], 0.95,
|
['==', ['feature-state', 'highlighted'], 1], 0.95,
|
||||||
['==', ['get', 'permitted'], 1], 0.93,
|
['==', ['get', 'permitted'], 1], 0.93,
|
||||||
0.9,
|
0.9,
|
||||||
] as never,
|
] as never,
|
||||||
@ -514,18 +516,42 @@ 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'], '']], ''],
|
||||||
[
|
|
||||||
'any',
|
|
||||||
['==', ['get', 'permitted'], 1],
|
['==', ['get', 'permitted'], 1],
|
||||||
['==', ['get', 'selected'], 1],
|
|
||||||
['==', ['get', 'highlighted'], 1],
|
|
||||||
],
|
|
||||||
] as unknown as unknown[];
|
] as unknown as unknown[];
|
||||||
|
|
||||||
if (!map.getLayer(labelId)) {
|
if (!map.getLayer(labelId)) {
|
||||||
|
needReorder = true;
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -549,8 +575,8 @@ export function useGlobeShipLayers(
|
|||||||
paint: {
|
paint: {
|
||||||
'text-color': [
|
'text-color': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
|
||||||
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
|
||||||
'rgba(226,232,240,0.92)',
|
'rgba(226,232,240,0.92)',
|
||||||
] as never,
|
] as never,
|
||||||
'text-halo-color': 'rgba(2,6,23,0.85)',
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
||||||
@ -568,6 +594,7 @@ export function useGlobeShipLayers(
|
|||||||
// Alarm badge symbol (above labels)
|
// Alarm badge symbol (above labels)
|
||||||
// Uses separate alarm source for stable rendering
|
// Uses separate alarm source for stable rendering
|
||||||
if (!map.getLayer(badgeId)) {
|
if (!map.getLayer(badgeId)) {
|
||||||
|
needReorder = true;
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
@ -600,7 +627,9 @@ export function useGlobeShipLayers(
|
|||||||
|
|
||||||
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||||
onGlobeShipsReady?.(true);
|
onGlobeShipsReady?.(true);
|
||||||
if (projection === 'globe') {
|
// needReorder: 새 레이어가 생성된 경우에만 reorder 호출
|
||||||
|
// 매 AIS poll마다 28개 moveLayer → style._changed 방지
|
||||||
|
if (projection === 'globe' && needReorder) {
|
||||||
reorderGlobeFeatureLayers();
|
reorderGlobeFeatureLayers();
|
||||||
}
|
}
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
@ -614,16 +643,50 @@ export function useGlobeShipLayers(
|
|||||||
projection,
|
projection,
|
||||||
settings.showShips,
|
settings.showShips,
|
||||||
overlays.shipLabels,
|
overlays.shipLabels,
|
||||||
|
overlays.shipPhotos,
|
||||||
globeShipGeoJson,
|
globeShipGeoJson,
|
||||||
alarmGeoJson,
|
alarmGeoJson,
|
||||||
selectedMmsi,
|
|
||||||
isBaseHighlightedMmsi,
|
|
||||||
mapSyncEpoch,
|
mapSyncEpoch,
|
||||||
reorderGlobeFeatureLayers,
|
reorderGlobeFeatureLayers,
|
||||||
onGlobeShipsReady,
|
onGlobeShipsReady,
|
||||||
alarmMmsiMap,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Feature-state로 상호작용 상태(selected/highlighted) 즉시 반영 — setData 없이
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || projection !== 'globe' || projectionBusyRef.current) return;
|
||||||
|
if (!map.isStyleLoaded() || !map.getSource('ships-globe-src')) return;
|
||||||
|
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
if (!map.isStyleLoaded()) return;
|
||||||
|
const src = 'ships-globe-src';
|
||||||
|
const alarmSrc = 'ships-globe-alarm-src';
|
||||||
|
for (const t of shipData) {
|
||||||
|
if (!isFiniteNumber(t.mmsi)) continue;
|
||||||
|
const id = Math.trunc(t.mmsi);
|
||||||
|
const s = t.mmsi === selectedMmsi ? 1 : 0;
|
||||||
|
const h = isBaseHighlightedMmsi(t.mmsi) ? 1 : 0;
|
||||||
|
try {
|
||||||
|
map.setFeatureState({ source: src, id }, { selected: s, highlighted: h });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (map.getSource(alarmSrc) && alarmMmsiMap) {
|
||||||
|
for (const t of shipData) {
|
||||||
|
if (!alarmMmsiMap.has(t.mmsi)) continue;
|
||||||
|
const id = Math.trunc(t.mmsi);
|
||||||
|
try {
|
||||||
|
map.setFeatureState(
|
||||||
|
{ source: alarmSrc, id },
|
||||||
|
{ selected: t.mmsi === selectedMmsi ? 1 : 0, highlighted: isBaseHighlightedMmsi(t.mmsi) ? 1 : 0 },
|
||||||
|
);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kickRepaint(map);
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [projection, selectedMmsi, isBaseHighlightedMmsi, shipData, alarmMmsiMap]);
|
||||||
|
|
||||||
// Alarm pulse breathing animation (rAF)
|
// Alarm pulse breathing animation (rAF)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@ -645,7 +708,7 @@ export function useGlobeShipLayers(
|
|||||||
if (map.getLayer('ships-globe-alarm-pulse')) {
|
if (map.getLayer('ships-globe-alarm-pulse')) {
|
||||||
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
|
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
|
||||||
'case',
|
'case',
|
||||||
['any', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'selected'], 1]],
|
['any', ['==', ['feature-state', 'highlighted'], 1], ['==', ['feature-state', 'selected'], 1]],
|
||||||
hoverR,
|
hoverR,
|
||||||
normalR,
|
normalR,
|
||||||
] as never);
|
] as never);
|
||||||
|
|||||||
@ -108,7 +108,9 @@ export function useNativeMapLayers(
|
|||||||
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
// 2. 데이터가 있는 source가 하나도 없으면 종료
|
||||||
const hasData = cfg.sources.some((s) => s.data != null);
|
const hasData = cfg.sources.some((s) => s.data != null);
|
||||||
if (!hasData) return;
|
if (!hasData) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
// isStyleLoaded() 가드 제거 — AIS poll의 setData()로 인해
|
||||||
|
// 일시적으로 false가 되어 데이터 업데이트가 스킵되는 문제 방지.
|
||||||
|
// 실패 시 try/catch가 처리하고, 다음 deps 변경 시 자연 재시도.
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 3. Source 생성/업데이트
|
// 3. Source 생성/업데이트
|
||||||
|
|||||||
@ -80,12 +80,17 @@ export function useProjectionToggle(
|
|||||||
};
|
};
|
||||||
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
||||||
|
|
||||||
|
const reorderRafRef = useRef(0);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||||
const reorderGlobeFeatureLayers = useCallback(() => {
|
const reorderGlobeFeatureLayers = useCallback(() => {
|
||||||
const map = mapRef.current;
|
if (!mapRef.current || projectionRef.current !== 'globe') return;
|
||||||
if (!map || projectionRef.current !== 'globe') return;
|
|
||||||
if (projectionBusyRef.current) return;
|
if (projectionBusyRef.current) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
if (reorderRafRef.current) return; // 이미 스케줄됨 — 프레임당 1회 실행
|
||||||
|
reorderRafRef.current = requestAnimationFrame(() => {
|
||||||
|
reorderRafRef.current = 0;
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m || !m.isStyleLoaded()) return;
|
||||||
|
|
||||||
const ordering = [
|
const ordering = [
|
||||||
'subcables-hitarea',
|
'subcables-hitarea',
|
||||||
@ -106,6 +111,10 @@ export function useProjectionToggle(
|
|||||||
'zones-fill',
|
'zones-fill',
|
||||||
'zones-line',
|
'zones-line',
|
||||||
'zones-label',
|
'zones-label',
|
||||||
|
'fleet-circles-ml',
|
||||||
|
'fc-lines-ml',
|
||||||
|
'pair-range-ml',
|
||||||
|
'pair-lines-ml',
|
||||||
'predict-vectors-outline',
|
'predict-vectors-outline',
|
||||||
'predict-vectors',
|
'predict-vectors',
|
||||||
'predict-vectors-hl-outline',
|
'predict-vectors-hl-outline',
|
||||||
@ -120,22 +129,18 @@ export function useProjectionToggle(
|
|||||||
'ships-globe-hover-halo',
|
'ships-globe-hover-halo',
|
||||||
'ships-globe-hover-outline',
|
'ships-globe-hover-outline',
|
||||||
'ships-globe-hover',
|
'ships-globe-hover',
|
||||||
'pair-lines-ml',
|
|
||||||
'fc-lines-ml',
|
|
||||||
'pair-range-ml',
|
|
||||||
'fleet-circles-ml-fill',
|
|
||||||
'fleet-circles-ml',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const layerId of ordering) {
|
for (const layerId of ordering) {
|
||||||
try {
|
try {
|
||||||
if (map.getLayer(layerId)) map.moveLayer(layerId);
|
if (m.getLayer(layerId)) m.moveLayer(layerId);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kickRepaint(map);
|
kickRepaint(m);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Projection toggle (mercator <-> globe)
|
// Projection toggle (mercator <-> globe)
|
||||||
|
|||||||
@ -13,10 +13,11 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
|||||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
|
|
||||||
/** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임.
|
/** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임.
|
||||||
* 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 70,000+로 폭증하므로
|
* 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 ~33x로 폭증하므로
|
||||||
* ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음. */
|
* ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음.
|
||||||
|
* 4수역 × 300pts × 33x ≈ 39,600 vertices (< 65535 limit). */
|
||||||
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
|
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
|
||||||
const MAX_PTS = 60;
|
const MAX_PTS = 300;
|
||||||
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
|
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
|
||||||
if (ring.length <= MAX_PTS) return ring;
|
if (ring.length <= MAX_PTS) return ring;
|
||||||
const step = Math.ceil(ring.length / MAX_PTS);
|
const step = Math.ceil(ring.length / MAX_PTS);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
|||||||
try {
|
try {
|
||||||
map.off('style.load', runOnce);
|
map.off('style.load', runOnce);
|
||||||
map.off('styledata', runOnce);
|
map.off('styledata', runOnce);
|
||||||
map.off('idle', runOnce);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -57,7 +56,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
|||||||
|
|
||||||
map.on('style.load', runOnce);
|
map.on('style.load', runOnce);
|
||||||
map.on('styledata', runOnce);
|
map.on('styledata', runOnce);
|
||||||
map.on('idle', runOnce);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (fired) return;
|
if (fired) return;
|
||||||
@ -66,7 +64,6 @@ export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
map.off('style.load', runOnce);
|
map.off('style.load', runOnce);
|
||||||
map.off('styledata', runOnce);
|
map.off('styledata', runOnce);
|
||||||
map.off('idle', runOnce);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,28 +41,46 @@ export function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) {
|
|||||||
return ['any', ...clauses] as unknown[];
|
return ['any', ...clauses] as unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeGlobeCircleRadiusExpr() {
|
// ── Globe circle radius zoom stops ──
|
||||||
const base3 = 4;
|
// MapLibre 제약: expression 당 zoom-based interpolate는 1개만 허용
|
||||||
const base7 = 6;
|
// → 하나의 interpolate 안에서 각 stop 값을 case로 분기
|
||||||
const base10 = 8;
|
const ZOOM_LEVELS = [3, 7, 10, 14, 18] as const;
|
||||||
const base14 = 12;
|
const BASE_VALUES = [4, 6, 8, 12, 32] as const;
|
||||||
const base18 = 32;
|
const SELECTED_VALUES = [4.6, 6.8, 9.0, 13.5, 36] as const;
|
||||||
|
const HIGHLIGHTED_VALUES = [4.2, 6.2, 8.2, 12.6, 34] as const;
|
||||||
|
|
||||||
return [
|
function buildStopsWithCase(getter: (key: string) => unknown[]) {
|
||||||
'interpolate',
|
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
|
||||||
['linear'],
|
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
|
||||||
['zoom'],
|
stops.push(ZOOM_LEVELS[i]);
|
||||||
3,
|
stops.push([
|
||||||
['case', ['==', ['get', 'selected'], 1], 4.6, ['==', ['get', 'highlighted'], 1], 4.2, base3],
|
'case',
|
||||||
7,
|
['==', getter('selected'), 1], SELECTED_VALUES[i],
|
||||||
['case', ['==', ['get', 'selected'], 1], 6.8, ['==', ['get', 'highlighted'], 1], 6.2, base7],
|
['==', getter('highlighted'), 1], HIGHLIGHTED_VALUES[i],
|
||||||
10,
|
BASE_VALUES[i],
|
||||||
['case', ['==', ['get', 'selected'], 1], 9.0, ['==', ['get', 'highlighted'], 1], 8.2, base10],
|
]);
|
||||||
14,
|
}
|
||||||
['case', ['==', ['get', 'selected'], 1], 13.5, ['==', ['get', 'highlighted'], 1], 12.6, base14],
|
return stops;
|
||||||
18,
|
}
|
||||||
['case', ['==', ['get', 'selected'], 1], 36, ['==', ['get', 'highlighted'], 1], 34, base18],
|
|
||||||
];
|
/** feature-state 기반 — 메인 선박 레이어 (halo, outline) */
|
||||||
|
export function makeGlobeCircleRadiusExpr() {
|
||||||
|
return buildStopsWithCase((key) => ['feature-state', key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GeoJSON property 기반 — hover overlay 레이어 */
|
||||||
|
export function makeGlobeCircleRadiusPropExpr() {
|
||||||
|
const stops: unknown[] = ['interpolate', ['linear'], ['zoom']];
|
||||||
|
for (let i = 0; i < ZOOM_LEVELS.length; i++) {
|
||||||
|
stops.push(ZOOM_LEVELS[i]);
|
||||||
|
stops.push([
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'selected'], 1], SELECTED_VALUES[i],
|
||||||
|
HIGHLIGHTED_VALUES[i],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return stops;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
|
export const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never;
|
||||||
|
export const GLOBE_SHIP_CIRCLE_RADIUS_PROP_EXPR = makeGlobeCircleRadiusPropExpr() as never;
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
130
apps/web/src/widgets/shipImage/ShipImageGallery.tsx
Normal file
130
apps/web/src/widgets/shipImage/ShipImageGallery.tsx
Normal file
@ -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;
|
||||||
143
apps/web/src/widgets/shipImage/ShipImageModal.tsx
Normal file
143
apps/web/src/widgets/shipImage/ShipImageModal.tsx
Normal file
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user