Compare commits

...

8 커밋

작성자 SHA1 메시지 날짜
71f235097c feat(api): signal-batch v2 API 마이그레이션 (#32) 2026-02-20 00:33:08 +09:00
1e742e5a3d Merge pull request 'fix(hook): commit-msg 정규식 통일 (template-common v1.2.0)' (#31) from fix/sync-commit-msg into develop 2026-02-18 21:17:32 +09:00
9437f5be3d fix(hook): commit-msg 정규식 통일 (template-common v1.2.0) 2026-02-18 21:16:43 +09:00
7cdc8bef2f Merge pull request 'fix(map): Globe 렌더링 안정화 및 툴팁 유지 개선' (#29) from feature/globe-rendering-optimization into develop
Reviewed-on: #29
2026-02-17 16:49:48 +09:00
59a5e6beac fix(map): 패널 선택 fly-to 즉시 반응 개선
- shipData를 ref로 관리하여 AIS poll마다 effect 재실행 방지
- isStyleLoaded 가드 제거 → try/catch로 즉시 실행
- duration 700→400ms로 단축
- selectedMmsi만 의존성으로 → 선택 시 1회만 fly-to 실행

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:47:38 +09:00
f9da13b694 fix(map): 패널 선택 시 fly-to 복원, 지도 클릭은 제외
- mapInitiatedSelectRef 도입: 지도 클릭 선택과 패널 선택을 구분
- 좌측 패널(선박 목록, 알람 목록) 선택 시 해당 위치로 fly-to
- 지도에서 직접 클릭/우클릭 선택 시에는 fly-to 비활성화
- onMapSelectMmsi 래퍼로 지도 내 선택 경로 통합 (Globe+Mercator)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:44:55 +09:00
7bca216c53 fix(map): Globe 렌더링 안정화 및 툴팁 유지 개선
- isStyleLoaded() 가드를 try/catch 패턴으로 교체 (AIS poll setData 중 렌더링 차단 방지)
- Globe 툴팁 buildTooltipRef 패턴 도입 (AIS poll 주기 변경 시 사라짐 방지)
- Globe 우클릭 컨텍스트 메뉴 isStyleLoaded 가드 제거
- 항적 가상 선박을 IconLayer에서 ScatterplotLayer(원형)로 변경
- useNativeMapLayers isStyleLoaded 가드 제거 (항적 레이어 셋업 스킵 방지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:38:51 +09:00
ccaf20804d Merge pull request 'feat(map): 오버레이 가시성 개선 + 경고 선박 강조' (#27) from feature/overlay-alarm-visibility into develop
Reviewed-on: #27
2026-02-17 11:01:56 +09:00
26개의 변경된 파일879개의 추가작업 그리고 962개의 파일을 삭제

파일 보기

@ -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(),

파일 보기

@ -0,0 +1,34 @@
/** 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;
}
/** GET /api/v2/vessels/recent-positions 응답 항목 */
export interface RecentVesselPositionDto {
mmsi: string;
lon: number;
lat: number;
sog: number;
cog: number;
shipNm: string;
shipTy: string;
shipKindCode: string;
nationalCode: string;
lastUpdate: string;
}

파일 보기

@ -0,0 +1,105 @@
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,
};
}
function adaptRecentVessel(dto: RecentVesselPositionDto): AisTarget {
return {
mmsi: Number(dto.mmsi),
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,
};
}
/* ── 공개 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,8 @@ 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;
message: string;
data: AisTarget[];
errorCode: string | null;
}; };

파일 보기

@ -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]);

파일 보기

@ -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);
} }

파일 보기

@ -41,12 +41,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 +102,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 +122,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]);

파일 보기

@ -94,6 +94,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 +239,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 +277,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 +299,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 +573,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 +608,8 @@ 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,
}, },
); );
@ -633,22 +644,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 +675,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 +695,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회 호출

파일 보기

@ -68,7 +68,6 @@ 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>;
}, },
) { ) {
@ -82,7 +81,7 @@ 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,
} = opts; } = opts;
const legacyTargets = useMemo(() => { const legacyTargets = useMemo(() => {
@ -219,12 +218,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);
}
} }
}, },
}; };

파일 보기

@ -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,14 +108,12 @@ 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,
@ -127,7 +124,7 @@ export function useGlobeShipLayers(
}; };
}), }),
}; };
}, [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 +138,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(() => {
@ -235,12 +229,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 +249,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 +285,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,8 +296,8 @@ 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, 0.16,
] as never, ] as never,
}, },
@ -309,6 +310,7 @@ export function useGlobeShipLayers(
} }
if (!map.getLayer(outlineId)) { if (!map.getLayer(outlineId)) {
needReorder = true;
try { try {
map.addLayer( map.addLayer(
{ {
@ -320,15 +322,15 @@ 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, 0.7,
] as never, ] as never,
@ -338,12 +340,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 +357,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 +381,7 @@ export function useGlobeShipLayers(
} }
if (!map.getLayer(symbolLiteId)) { if (!map.getLayer(symbolLiteId)) {
needReorder = true;
try { try {
map.addLayer( map.addLayer(
{ {
@ -451,6 +451,7 @@ export function useGlobeShipLayers(
} }
if (!map.getLayer(symbolId)) { if (!map.getLayer(symbolId)) {
needReorder = true;
try { try {
map.addLayer( map.addLayer(
{ {
@ -462,12 +463,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 +497,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,
@ -517,15 +514,11 @@ export function useGlobeShipLayers(
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 +542,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 +561,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 +594,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);
@ -616,14 +612,47 @@ export function useGlobeShipLayers(
overlays.shipLabels, overlays.shipLabels,
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 +674,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);

파일 보기

@ -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;

파일 보기

@ -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,9 @@ 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, changeOrigin: true,
secure: false, secure: false,
}, },