Merge pull request 'feat(map): 오버레이 가시성 개선 + 경고 선박 강조' (#27) from feature/overlay-alarm-visibility into develop
Reviewed-on: #27
This commit is contained in:
커밋
ccaf20804d
216
apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts
Normal file
216
apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* 오버레이/경고 시각 검증용 더미 데이터 — 김개발(DEV) 모드 전용.
|
||||||
|
*
|
||||||
|
* 서해 해역에 12척의 가상 선박을 배치하여 다음 시나리오를 재현:
|
||||||
|
* - 정상 쌍끌이 (pair link, ~1 NM)
|
||||||
|
* - 이격 쌍끌이 (pair_separation alarm, ~8 NM)
|
||||||
|
* - 선단 원 (fleet circle, 5척 동일 선주)
|
||||||
|
* - 환적 의심 (transshipment alarm, FC ↔ PS < 0.5 NM)
|
||||||
|
* - AIS 지연 (ais_stale alarm, 2시간 전 타임스탬프)
|
||||||
|
* - 수역 이탈 (zone_violation alarm, PT가 zone 4에 위치)
|
||||||
|
* - closed_season은 계절(월)에 따라 자동 판정 (2월에는 미발생)
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* DashboardPage에서 isDevMode일 때 targetsInScope + legacyHits에 병합.
|
||||||
|
* 기존 computePairLinks / computeFcLinks / computeFleetCircles /
|
||||||
|
* computeLegacyAlarms 파이프라인이 자동으로 오버레이/경고를 생성.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
|
||||||
|
// ── 타임스탬프 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FRESH_TS = new Date().toISOString();
|
||||||
|
const STALE_TS = new Date(Date.now() - 120 * 60_000).toISOString(); // 2시간 전 → ais_stale
|
||||||
|
|
||||||
|
// ── 팩토리 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeAis(o: Partial<AisTarget> & Pick<AisTarget, 'mmsi' | 'lat' | 'lon'>): AisTarget {
|
||||||
|
return {
|
||||||
|
imo: 0,
|
||||||
|
name: '',
|
||||||
|
callsign: '',
|
||||||
|
vesselType: '',
|
||||||
|
heading: o.cog ?? 0,
|
||||||
|
sog: 0,
|
||||||
|
cog: 0,
|
||||||
|
rot: 0,
|
||||||
|
length: 30,
|
||||||
|
width: 8,
|
||||||
|
draught: 4,
|
||||||
|
destination: '',
|
||||||
|
eta: '',
|
||||||
|
status: 'underway',
|
||||||
|
messageTimestamp: FRESH_TS,
|
||||||
|
receivedDate: FRESH_TS,
|
||||||
|
source: 'mock',
|
||||||
|
classType: 'A',
|
||||||
|
...o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLegacy(
|
||||||
|
o: Partial<LegacyVesselInfo> & Pick<LegacyVesselInfo, 'permitNo' | 'shipCode' | 'mmsiList'>,
|
||||||
|
): LegacyVesselInfo {
|
||||||
|
return {
|
||||||
|
shipNameRoman: '',
|
||||||
|
shipNameCn: null,
|
||||||
|
ton: 100,
|
||||||
|
callSign: '',
|
||||||
|
workSeaArea: '서해',
|
||||||
|
workTerm1: '2025-01-01',
|
||||||
|
workTerm2: '2025-12-31',
|
||||||
|
quota: '',
|
||||||
|
ownerCn: null,
|
||||||
|
ownerRoman: null,
|
||||||
|
pairPermitNo: null,
|
||||||
|
pairShipNameCn: null,
|
||||||
|
checklistSheet: null,
|
||||||
|
sources: { permittedList: true, checklist: false, fleet906: false },
|
||||||
|
...o,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 선박 정의 (12척) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 1 — 정상 쌍끌이 (간격 ~1 NM, 경고 없음)
|
||||||
|
* 위치: 서해남부(zone 3) 125.3°E 34.0°N 부근
|
||||||
|
*/
|
||||||
|
const PT_01_AIS = makeAis({ mmsi: 990001, lat: 34.00, lon: 125.30, sog: 3.3, cog: 45, name: 'MOCK VESSEL 1' });
|
||||||
|
const PT_02_AIS = makeAis({ mmsi: 990002, lat: 34.01, lon: 125.32, sog: 3.3, cog: 45, name: 'MOCK VESSEL 2' });
|
||||||
|
|
||||||
|
const PT_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P001', shipCode: 'PT', mmsiList: [990001],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 1', shipNameCn: '模拟渔船一号',
|
||||||
|
pairPermitNo: 'MOCK-P002', pairShipNameCn: '模拟渔船二号',
|
||||||
|
ownerRoman: 'MOCK Owner A', ownerCn: '模拟A渔业',
|
||||||
|
});
|
||||||
|
const PT_02_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P002', shipCode: 'PT-S', mmsiList: [990002],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 2', shipNameCn: '模拟渔船二号',
|
||||||
|
pairPermitNo: 'MOCK-P001', pairShipNameCn: '模拟渔船一号',
|
||||||
|
ownerRoman: 'MOCK Owner A', ownerCn: '模拟A渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 2 — 이격 쌍끌이 (간격 ~8 NM → pair_separation alarm)
|
||||||
|
* 위치: 서해남부(zone 3) 125.0°E 34.5°N 부근
|
||||||
|
*/
|
||||||
|
const PT_03_AIS = makeAis({ mmsi: 990003, lat: 34.50, lon: 125.00, sog: 3.5, cog: 90, name: 'MOCK VESSEL 3' });
|
||||||
|
const PT_04_AIS = makeAis({ mmsi: 990004, lat: 34.60, lon: 125.12, sog: 3.5, cog: 90, name: 'MOCK VESSEL 4' });
|
||||||
|
|
||||||
|
const PT_03_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P003', shipCode: 'PT', mmsiList: [990003],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 3', shipNameCn: '模拟渔船三号',
|
||||||
|
pairPermitNo: 'MOCK-P004', pairShipNameCn: '模拟渔船四号',
|
||||||
|
ownerRoman: 'MOCK Owner B', ownerCn: '模拟B渔业',
|
||||||
|
});
|
||||||
|
const PT_04_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P004', shipCode: 'PT-S', mmsiList: [990004],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 4', shipNameCn: '模拟渔船四号',
|
||||||
|
pairPermitNo: 'MOCK-P003', pairShipNameCn: '模拟渔船三号',
|
||||||
|
ownerRoman: 'MOCK Owner B', ownerCn: '模拟B渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 3 — 선단 (동일 선주 5척 → fleet circle)
|
||||||
|
* 위치: 서해중간(zone 4) 124.8°E 35.2°N 부근
|
||||||
|
* #11(GN)은 AIS 지연 2시간 → ais_stale alarm 동시 발생
|
||||||
|
*/
|
||||||
|
const GN_01_AIS = makeAis({ mmsi: 990005, lat: 35.20, lon: 124.80, sog: 1.0, cog: 180, name: 'MOCK VESSEL 5' });
|
||||||
|
const GN_02_AIS = makeAis({ mmsi: 990006, lat: 35.22, lon: 124.85, sog: 1.2, cog: 170, name: 'MOCK VESSEL 6' });
|
||||||
|
const GN_03_AIS = makeAis({ mmsi: 990007, lat: 35.18, lon: 124.82, sog: 0.8, cog: 200, name: 'MOCK VESSEL 7' });
|
||||||
|
const OT_01_AIS = makeAis({ mmsi: 990008, lat: 35.25, lon: 124.78, sog: 3.5, cog: 160, name: 'MOCK VESSEL 8' });
|
||||||
|
const GN_04_AIS = makeAis({
|
||||||
|
mmsi: 990011, lat: 35.00, lon: 125.20, sog: 1.5, cog: 190, name: 'MOCK VESSEL 10',
|
||||||
|
messageTimestamp: STALE_TS, receivedDate: STALE_TS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const GN_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P005', shipCode: 'GN', mmsiList: [990005],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 5', shipNameCn: '模拟渔船五号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
const GN_02_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P006', shipCode: 'GN', mmsiList: [990006],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 6', shipNameCn: '模拟渔船六号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
const GN_03_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P007', shipCode: 'GN', mmsiList: [990007],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 7', shipNameCn: '模拟渔船七号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
const OT_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P008', shipCode: 'OT', mmsiList: [990008],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 8', shipNameCn: '模拟渔船八号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
const GN_04_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P011', shipCode: 'GN', mmsiList: [990011],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 10', shipNameCn: '模拟渔船十号',
|
||||||
|
ownerRoman: 'MOCK Owner C', ownerCn: '模拟C渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 4 — 환적 의심 (FC ↔ PS 거리 ~0.15 NM → transshipment alarm)
|
||||||
|
* 위치: 서해남부(zone 3) 125.5°E 34.3°N 부근
|
||||||
|
*/
|
||||||
|
const FC_01_AIS = makeAis({ mmsi: 990009, lat: 34.30, lon: 125.50, sog: 1.0, cog: 0, name: 'MOCK CARRIER 1' });
|
||||||
|
const PS_01_AIS = makeAis({ mmsi: 990010, lat: 34.302, lon: 125.502, sog: 0.5, cog: 10, name: 'MOCK VESSEL 9' });
|
||||||
|
|
||||||
|
const FC_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P009', shipCode: 'FC', mmsiList: [990009],
|
||||||
|
shipNameRoman: 'MOCK CARRIER 1', shipNameCn: '模拟运船一号',
|
||||||
|
ownerRoman: 'MOCK Owner D', ownerCn: '模拟D渔业',
|
||||||
|
});
|
||||||
|
const PS_01_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P010', shipCode: 'PS', mmsiList: [990010],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 9', shipNameCn: '模拟渔船九号',
|
||||||
|
ownerRoman: 'MOCK Owner E', ownerCn: '模拟E渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group 5 — 수역 이탈 (PT가 zone 4 에 위치 → zone_violation alarm)
|
||||||
|
* PT는 zone 2,3만 허가. zone 4(서해중간)에 위치하면 이탈 판정.
|
||||||
|
* 위치: 서해중간(zone 4) 125.0°E 36.5°N 부근
|
||||||
|
*/
|
||||||
|
const PT_05_AIS = makeAis({ mmsi: 990012, lat: 36.50, lon: 125.00, sog: 3.3, cog: 270, name: 'MOCK VESSEL 11' });
|
||||||
|
|
||||||
|
const PT_05_LEG = makeLegacy({
|
||||||
|
permitNo: 'MOCK-P012', shipCode: 'PT', mmsiList: [990012],
|
||||||
|
shipNameRoman: 'MOCK VESSEL 11', shipNameCn: '模拟渔船十一号',
|
||||||
|
ownerRoman: 'MOCK Owner F', ownerCn: '模拟F渔业',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 공개 API ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 더미 AIS 타겟 (12척) */
|
||||||
|
export const MOCK_AIS_TARGETS: AisTarget[] = [
|
||||||
|
PT_01_AIS, PT_02_AIS, // Group 1: 정상 쌍끌이
|
||||||
|
PT_03_AIS, PT_04_AIS, // Group 2: 이격 쌍끌이
|
||||||
|
GN_01_AIS, GN_02_AIS, GN_03_AIS, OT_01_AIS, GN_04_AIS, // Group 3: 선단 + AIS 지연
|
||||||
|
FC_01_AIS, PS_01_AIS, // Group 4: 환적 의심
|
||||||
|
PT_05_AIS, // Group 5: 수역 이탈
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 더미 legacy 매칭 엔트리 (MMSI → LegacyVesselInfo) */
|
||||||
|
export const MOCK_LEGACY_ENTRIES: [number, LegacyVesselInfo][] = [
|
||||||
|
[990001, PT_01_LEG],
|
||||||
|
[990002, PT_02_LEG],
|
||||||
|
[990003, PT_03_LEG],
|
||||||
|
[990004, PT_04_LEG],
|
||||||
|
[990005, GN_01_LEG],
|
||||||
|
[990006, GN_02_LEG],
|
||||||
|
[990007, GN_03_LEG],
|
||||||
|
[990008, OT_01_LEG],
|
||||||
|
[990009, FC_01_LEG],
|
||||||
|
[990010, PS_01_LEG],
|
||||||
|
[990011, GN_04_LEG],
|
||||||
|
[990012, PT_05_LEG],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 더미 MMSI 집합 — 필터링/하이라이팅에 활용 */
|
||||||
|
export const MOCK_MMSI_SET = new Set(MOCK_AIS_TARGETS.map((t) => t.mmsi));
|
||||||
@ -88,3 +88,21 @@ export const LEGACY_ALARM_KIND_LABEL: Record<LegacyAlarmKind, string> = {
|
|||||||
ais_stale: "AIS 지연",
|
ais_stale: "AIS 지연",
|
||||||
zone_violation: "수역 이탈",
|
zone_violation: "수역 이탈",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 경고 우선순위 (낮→높). 배열 뒤가 높은 우선순위. */
|
||||||
|
export const ALARM_KIND_PRIORITY: LegacyAlarmKind[] = [
|
||||||
|
"ais_stale",
|
||||||
|
"closed_season",
|
||||||
|
"transshipment",
|
||||||
|
"zone_violation",
|
||||||
|
"pair_separation",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 경고 배지 — 지도 위 선박 옆에 표시할 약어 + 색상 */
|
||||||
|
export const ALARM_BADGE: Record<LegacyAlarmKind, { label: string; color: string; rgba: [number, number, number, number] }> = {
|
||||||
|
pair_separation: { label: "이", color: "#ef4444", rgba: [239, 68, 68, 200] },
|
||||||
|
zone_violation: { label: "수", color: "#a855f7", rgba: [168, 85, 247, 200] },
|
||||||
|
transshipment: { label: "환", color: "#f97316", rgba: [249, 115, 22, 200] },
|
||||||
|
closed_season: { label: "휴", color: "#eab308", rgba: [234, 179, 8, 200] },
|
||||||
|
ais_stale: { label: "A", color: "#6b7280", rgba: [107, 114, 128, 200] },
|
||||||
|
};
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { useAuth } from "../../shared/auth";
|
import { useAuth } from "../../shared/auth";
|
||||||
import { useTheme } from "../../shared/hooks";
|
import { useTheme } from "../../shared/hooks";
|
||||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
|
||||||
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
import { ALARM_KIND_PRIORITY, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||||
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
|
||||||
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||||
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
||||||
@ -36,6 +36,7 @@ import {
|
|||||||
deriveLegacyVessels,
|
deriveLegacyVessels,
|
||||||
filterByShipCodes,
|
filterByShipCodes,
|
||||||
} from "../../features/legacyDashboard/model/derive";
|
} from "../../features/legacyDashboard/model/derive";
|
||||||
|
import { MOCK_AIS_TARGETS, MOCK_LEGACY_ENTRIES } from "../../features/legacyDashboard/dev/mockOverlayData";
|
||||||
import { useDashboardState } from "./useDashboardState";
|
import { useDashboardState } from "./useDashboardState";
|
||||||
import type { Bbox } from "./useDashboardState";
|
import type { Bbox } from "./useDashboardState";
|
||||||
import { DashboardSidebar } from "./DashboardSidebar";
|
import { DashboardSidebar } from "./DashboardSidebar";
|
||||||
@ -62,6 +63,7 @@ export function DashboardPage() {
|
|||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const uid = user?.id ?? null;
|
const uid = user?.id ?? null;
|
||||||
|
const isDevMode = user?.name?.includes('(DEV)') ?? false;
|
||||||
|
|
||||||
// ── Data fetching ──
|
// ── Data fetching ──
|
||||||
const { data: zones, error: zonesError } = useZones();
|
const { data: zones, error: zonesError } = useZones();
|
||||||
@ -142,11 +144,19 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
// ── Derived data ──
|
// ── Derived data ──
|
||||||
const targetsInScope = useMemo(() => {
|
const targetsInScope = useMemo(() => {
|
||||||
if (!useViewportFilter || !viewBbox) return targets;
|
const base = (!useViewportFilter || !viewBbox)
|
||||||
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
? targets
|
||||||
}, [targets, useViewportFilter, viewBbox]);
|
: targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
||||||
|
return isDevMode ? [...base, ...MOCK_AIS_TARGETS] : base;
|
||||||
|
}, [targets, useViewportFilter, viewBbox, isDevMode]);
|
||||||
|
|
||||||
const legacyHits = useMemo(() => buildLegacyHitMap(targetsInScope, legacyIndex), [targetsInScope, legacyIndex]);
|
const legacyHits = useMemo(() => {
|
||||||
|
const hits = buildLegacyHitMap(targetsInScope, legacyIndex);
|
||||||
|
if (isDevMode) {
|
||||||
|
for (const [mmsi, info] of MOCK_LEGACY_ENTRIES) hits.set(mmsi, info);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}, [targetsInScope, legacyIndex, isDevMode]);
|
||||||
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]);
|
||||||
|
|
||||||
@ -195,6 +205,17 @@ export function DashboardPage() {
|
|||||||
return alarms.filter((a) => enabled.has(a.kind));
|
return alarms.filter((a) => enabled.has(a.kind));
|
||||||
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
|
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
|
||||||
|
|
||||||
|
const alarmMmsiMap = useMemo(() => {
|
||||||
|
const m = new Map<number, LegacyAlarmKind>();
|
||||||
|
for (const kind of ALARM_KIND_PRIORITY) {
|
||||||
|
for (const alarm of filteredAlarms) {
|
||||||
|
if (alarm.kind !== kind) continue;
|
||||||
|
for (const mmsi of alarm.relatedMmsi) m.set(mmsi, kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [filteredAlarms]);
|
||||||
|
|
||||||
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||||
@ -348,6 +369,7 @@ export function DashboardPage() {
|
|||||||
onCloseTrackMenu={handleCloseTrackMenu}
|
onCloseTrackMenu={handleCloseTrackMenu}
|
||||||
onOpenTrackMenu={handleOpenTrackMenu}
|
onOpenTrackMenu={handleOpenTrackMenu}
|
||||||
onMapReady={handleMapReady}
|
onMapReady={handleMapReady}
|
||||||
|
alarmMmsiMap={alarmMmsiMap}
|
||||||
/>
|
/>
|
||||||
<GlobalTrackReplayPanel />
|
<GlobalTrackReplayPanel />
|
||||||
<WeatherPanel
|
<WeatherPanel
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export function Map3D({
|
|||||||
onCloseTrackMenu,
|
onCloseTrackMenu,
|
||||||
onOpenTrackMenu,
|
onOpenTrackMenu,
|
||||||
onMapReady,
|
onMapReady,
|
||||||
|
alarmMmsiMap,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// ── Shared refs ──────────────────────────────────────────────────────
|
// ── Shared refs ──────────────────────────────────────────────────────
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -563,7 +564,7 @@ export function Map3D({
|
|||||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||||
onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
||||||
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||||
onGlobeShipsReady,
|
onGlobeShipsReady, alarmMmsiMap,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -597,7 +598,7 @@ export function Map3D({
|
|||||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||||
ensureMercatorOverlay, projectionRef,
|
ensureMercatorOverlay, projectionRef, alarmMmsiMap,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
import { useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { type PickingInfo } from '@deck.gl/core';
|
import { type PickingInfo } from '@deck.gl/core';
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||||
|
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
||||||
@ -67,6 +69,7 @@ export function useDeckLayers(
|
|||||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||||
projectionRef: MutableRefObject<MapProjectionId>;
|
projectionRef: MutableRefObject<MapProjectionId>;
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
@ -79,7 +82,7 @@ export function useDeckLayers(
|
|||||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||||
ensureMercatorOverlay, projectionRef,
|
ensureMercatorOverlay, projectionRef, alarmMmsiMap,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const legacyTargets = useMemo(() => {
|
const legacyTargets = useMemo(() => {
|
||||||
@ -99,6 +102,14 @@ export function useDeckLayers(
|
|||||||
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||||
}, [legacyTargets, shipHighlightSet]);
|
}, [legacyTargets, shipHighlightSet]);
|
||||||
|
|
||||||
|
const alarmTargets = useMemo(() => {
|
||||||
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
|
||||||
|
return shipData.filter((t) => alarmMmsiMap.has(t.mmsi));
|
||||||
|
}, [shipData, alarmMmsiMap]);
|
||||||
|
|
||||||
|
const mercatorLayersRef = useRef<unknown[]>([]);
|
||||||
|
const alarmRafRef = useRef(0);
|
||||||
|
|
||||||
// Mercator Deck layers
|
// Mercator Deck layers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@ -147,11 +158,16 @@ export function useDeckLayers(
|
|||||||
onSelectMmsi,
|
onSelectMmsi,
|
||||||
onToggleHighlightMmsi,
|
onToggleHighlightMmsi,
|
||||||
onDeckSelectOrHighlight,
|
onDeckSelectOrHighlight,
|
||||||
|
alarmTargets,
|
||||||
|
alarmMmsiMap,
|
||||||
|
alarmPulseRadius: 8,
|
||||||
|
alarmPulseHoverRadius: 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
||||||
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
||||||
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
||||||
|
mercatorLayersRef.current = normalizedLayers;
|
||||||
const deckProps = {
|
const deckProps = {
|
||||||
layers: normalizedLayers,
|
layers: normalizedLayers,
|
||||||
getTooltip: (info: PickingInfo) => {
|
getTooltip: (info: PickingInfo) => {
|
||||||
@ -239,6 +255,7 @@ export function useDeckLayers(
|
|||||||
overlays.pairLines,
|
overlays.pairLines,
|
||||||
overlays.fcLines,
|
overlays.fcLines,
|
||||||
overlays.fleetCircles,
|
overlays.fleetCircles,
|
||||||
|
overlays.shipLabels,
|
||||||
settings.showDensity,
|
settings.showDensity,
|
||||||
settings.showShips,
|
settings.showShips,
|
||||||
trackReplayDeckLayers,
|
trackReplayDeckLayers,
|
||||||
@ -252,8 +269,73 @@ export function useDeckLayers(
|
|||||||
toFleetMmsiList,
|
toFleetMmsiList,
|
||||||
touchDeckHoverState,
|
touchDeckHoverState,
|
||||||
hasAuxiliarySelectModifier,
|
hasAuxiliarySelectModifier,
|
||||||
|
alarmTargets,
|
||||||
|
alarmMmsiMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Mercator alarm pulse breathing animation (rAF)
|
||||||
|
useEffect(() => {
|
||||||
|
if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) {
|
||||||
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
|
alarmRafRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
// 프로젝션 전환 중에는 overlay에 접근하지 않음 — WebGL 자원 무효화 방지
|
||||||
|
if (projectionBusyRef.current) {
|
||||||
|
alarmRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentOverlay = overlayRef.current;
|
||||||
|
if (!currentOverlay) {
|
||||||
|
alarmRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2;
|
||||||
|
const normalR = 8 + t * 6;
|
||||||
|
const hoverR = 12 + t * 6;
|
||||||
|
|
||||||
|
const pulseLyr = new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'alarm-pulse',
|
||||||
|
data: alarmTargets,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
filled: true,
|
||||||
|
stroked: false,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: (d) => {
|
||||||
|
const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi);
|
||||||
|
return isHover ? hoverR : normalR;
|
||||||
|
},
|
||||||
|
getFillColor: (d) => {
|
||||||
|
const kind = alarmMmsiMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
updateTriggers: { getRadius: [normalR, hoverR] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = mercatorLayersRef.current.map((l) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(l as any)?.id === 'alarm-pulse' ? pulseLyr : l,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentOverlay.setProps({ layers: updated } as never);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
alarmRafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
alarmRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current);
|
||||||
|
alarmRafRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]);
|
||||||
|
|
||||||
// Globe Deck overlay
|
// Globe Deck overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
import type maplibregl from 'maplibre-gl';
|
import type maplibregl from 'maplibre-gl';
|
||||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||||
import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types';
|
||||||
@ -25,6 +25,16 @@ import { circleRingLngLat } from '../lib/geometry';
|
|||||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
import { dashifyLine } from '../lib/dashifyLine';
|
import { dashifyLine } from '../lib/dashifyLine';
|
||||||
|
|
||||||
|
// ── Overlay line width constants ──
|
||||||
|
const FC_LINE_W_NORMAL = 2.2;
|
||||||
|
const FC_LINE_W_HL = 3.2;
|
||||||
|
const FLEET_LINE_W_NORMAL = 2.0;
|
||||||
|
const FLEET_LINE_W_HL = 3.0;
|
||||||
|
|
||||||
|
// ── Breathing animation constants ──
|
||||||
|
const BREATHE_AMP = 2.0;
|
||||||
|
const BREATHE_PERIOD_MS = 1200;
|
||||||
|
|
||||||
/** Globe FC lines + fleet circles 오버레이 */
|
/** Globe FC lines + fleet circles 오버레이 */
|
||||||
export function useGlobeFcFleetOverlay(
|
export function useGlobeFcFleetOverlay(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
@ -45,6 +55,7 @@ export function useGlobeFcFleetOverlay(
|
|||||||
overlays, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
overlays, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
|
||||||
// FC lines
|
// FC lines
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -119,7 +130,7 @@ export function useGlobeFcFleetOverlay(
|
|||||||
FC_LINE_SUSPICIOUS_ML,
|
FC_LINE_SUSPICIOUS_ML,
|
||||||
FC_LINE_NORMAL_ML,
|
FC_LINE_NORMAL_ML,
|
||||||
] as never,
|
] as never,
|
||||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never,
|
'line-width': ['case', ['==', ['get', 'highlighted'], 1], FC_LINE_W_HL, FC_LINE_W_NORMAL] as never,
|
||||||
'line-opacity': 0.9,
|
'line-opacity': 0.9,
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
@ -244,7 +255,7 @@ export function useGlobeFcFleetOverlay(
|
|||||||
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': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
||||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never,
|
'line-width': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never,
|
||||||
'line-opacity': 0.85,
|
'line-opacity': 0.85,
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
@ -327,7 +338,7 @@ export function useGlobeFcFleetOverlay(
|
|||||||
);
|
);
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'fc-lines-ml', 'line-width',
|
'fc-lines-ml', 'line-width',
|
||||||
['case', fcEndpointHighlightExpr, 2.0, 1.3] as never,
|
['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -337,7 +348,7 @@ export function useGlobeFcFleetOverlay(
|
|||||||
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-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
|
||||||
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
|
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@ -353,4 +364,55 @@ export function useGlobeFcFleetOverlay(
|
|||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]);
|
}, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]);
|
||||||
|
|
||||||
|
// Breathing animation for highlighted fc/fleet overlays
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
const hasFcHover = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0;
|
||||||
|
if (!map || (!hasFleetHover && !hasFcHover) || projection !== 'globe') {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]);
|
||||||
|
const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0
|
||||||
|
? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList)
|
||||||
|
: false;
|
||||||
|
const fleetOwnerMatchExpr = hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false;
|
||||||
|
const fleetMemberExpr = hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false;
|
||||||
|
const fleetHighlightExpr =
|
||||||
|
hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0
|
||||||
|
? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||||
|
try {
|
||||||
|
if (map.getLayer('fc-lines-ml') && fcEndpointHighlightExpr !== false) {
|
||||||
|
const hlW = FC_LINE_W_HL + t * BREATHE_AMP;
|
||||||
|
map.setPaintProperty('fc-lines-ml', 'line-width',
|
||||||
|
['case', fcEndpointHighlightExpr, hlW, FC_LINE_W_NORMAL] as never);
|
||||||
|
}
|
||||||
|
if (map.getLayer('fleet-circles-ml') && fleetHighlightExpr !== false) {
|
||||||
|
const hlW = FLEET_LINE_W_HL + t * BREATHE_AMP;
|
||||||
|
map.setPaintProperty('fleet-circles-ml', 'line-width',
|
||||||
|
['case', fleetHighlightExpr, hlW, FLEET_LINE_W_NORMAL] as never);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, type MutableRefObject } from 'react';
|
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
import type maplibregl from 'maplibre-gl';
|
import type maplibregl from 'maplibre-gl';
|
||||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||||
import type { PairLink } from '../../../features/legacyDashboard/model/types';
|
import type { PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
@ -16,6 +16,17 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
|||||||
import { circleRingLngLat } from '../lib/geometry';
|
import { circleRingLngLat } from '../lib/geometry';
|
||||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
|
|
||||||
|
// ── Overlay line width constants ──
|
||||||
|
const PAIR_LINE_W_NORMAL = 2.5;
|
||||||
|
const PAIR_LINE_W_WARN = 3.5;
|
||||||
|
const PAIR_LINE_W_HL = 4.5;
|
||||||
|
const PAIR_RANGE_W_NORMAL = 1.8;
|
||||||
|
const PAIR_RANGE_W_HL = 2.8;
|
||||||
|
|
||||||
|
// ── Breathing animation constants ──
|
||||||
|
const BREATHE_AMP = 2.0;
|
||||||
|
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>,
|
||||||
@ -30,6 +41,7 @@ export function useGlobePairOverlay(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
|
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
|
||||||
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
|
||||||
// Pair lines
|
// Pair lines
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -96,9 +108,9 @@ export function useGlobePairOverlay(
|
|||||||
] as never,
|
] as never,
|
||||||
'line-width': [
|
'line-width': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'highlighted'], 1], 2.8,
|
['==', ['get', 'highlighted'], 1], PAIR_LINE_W_HL,
|
||||||
['boolean', ['get', 'warn'], false], 2.2,
|
['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN,
|
||||||
1.4,
|
PAIR_LINE_W_NORMAL,
|
||||||
] as never,
|
] as never,
|
||||||
'line-opacity': 0.9,
|
'line-opacity': 0.9,
|
||||||
},
|
},
|
||||||
@ -197,7 +209,11 @@ export function useGlobePairOverlay(
|
|||||||
id: layerId,
|
id: layerId,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
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': [
|
||||||
'case',
|
'case',
|
||||||
@ -207,7 +223,7 @@ export function useGlobePairOverlay(
|
|||||||
PAIR_RANGE_WARN_ML,
|
PAIR_RANGE_WARN_ML,
|
||||||
PAIR_RANGE_NORMAL_ML,
|
PAIR_RANGE_NORMAL_ML,
|
||||||
] as never,
|
] as never,
|
||||||
'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never,
|
'line-width': ['case', ['==', ['get', 'highlighted'], 1], PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never,
|
||||||
'line-opacity': 0.85,
|
'line-opacity': 0.85,
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification,
|
} as unknown as LayerSpecification,
|
||||||
@ -230,7 +246,7 @@ export function useGlobePairOverlay(
|
|||||||
};
|
};
|
||||||
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
}, [projection, overlays.pairRange, pairLinks, hoveredPairMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
||||||
|
|
||||||
// Pair paint state updates
|
// Pair paint state updates + breathing animation
|
||||||
// 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' || projectionBusyRef.current) return;
|
||||||
@ -249,7 +265,7 @@ export function useGlobePairOverlay(
|
|||||||
);
|
);
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'pair-lines-ml', 'line-width',
|
'pair-lines-ml', 'line-width',
|
||||||
['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never,
|
['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -264,7 +280,7 @@ export function useGlobePairOverlay(
|
|||||||
);
|
);
|
||||||
map.setPaintProperty(
|
map.setPaintProperty(
|
||||||
'pair-range-ml', 'line-width',
|
'pair-range-ml', 'line-width',
|
||||||
['case', pairHighlightExpr, 1.6, 1.0] as never,
|
['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -281,4 +297,44 @@ export function useGlobePairOverlay(
|
|||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
|
}, [mapSyncEpoch, hoveredPairMmsiList, projection, updatePairPaintStates]);
|
||||||
|
|
||||||
|
// Breathing animation for highlighted pair overlays
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairHighlightExpr = makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||||
|
try {
|
||||||
|
if (map.getLayer('pair-lines-ml')) {
|
||||||
|
const hlW = PAIR_LINE_W_HL + t * BREATHE_AMP;
|
||||||
|
map.setPaintProperty('pair-lines-ml', 'line-width',
|
||||||
|
['case', pairHighlightExpr, hlW, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never);
|
||||||
|
}
|
||||||
|
if (map.getLayer('pair-range-ml')) {
|
||||||
|
const hlW = PAIR_RANGE_W_HL + t * BREATHE_AMP;
|
||||||
|
map.setPaintProperty('pair-range-ml', 'line-width',
|
||||||
|
['case', pairHighlightExpr, hlW, PAIR_RANGE_W_NORMAL] as never);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [hoveredPairMmsiList, projection]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
import { useEffect, type MutableRefObject } from 'react';
|
import { useEffect, type MutableRefObject } from 'react';
|
||||||
import type maplibregl from 'maplibre-gl';
|
import type maplibregl from 'maplibre-gl';
|
||||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { onMapStyleReady } from '../lib/mapCore';
|
||||||
|
|
||||||
/** Mercator 모드 선명 라벨 (허가 선박 + 선택/하이라이트) */
|
/**
|
||||||
|
* Mercator 모드 선명 라벨 — 레거시 MapLibre 레이어 정리 전용.
|
||||||
|
* 실제 라벨 렌더링은 Deck.gl TextLayer (deckLayerFactories.ts)에서 처리.
|
||||||
|
*/
|
||||||
export function useGlobeShipLabels(
|
export function useGlobeShipLabels(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
_projectionBusyRef: MutableRefObject<boolean>,
|
||||||
opts: {
|
opts: {
|
||||||
projection: MapProjectionId;
|
projection: MapProjectionId;
|
||||||
settings: Map3DSettings;
|
settings: Map3DSettings;
|
||||||
@ -20,12 +23,10 @@ export function useGlobeShipLabels(
|
|||||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||||
selectedMmsi: number | null;
|
selectedMmsi: number | null;
|
||||||
mapSyncEpoch: number;
|
mapSyncEpoch: number;
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const { mapSyncEpoch } = opts;
|
||||||
projection, settings, shipData, shipHighlightSet,
|
|
||||||
overlays, legacyHits, selectedMmsi, mapSyncEpoch,
|
|
||||||
} = opts;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@ -48,117 +49,16 @@ export function useGlobeShipLabels(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
// Mercator ship labels are now rendered via Deck.gl TextLayer
|
||||||
|
// (see buildMercatorDeckLayers in deckLayerFactories.ts).
|
||||||
|
// Always clean up any stale MapLibre label layer.
|
||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
if (projection !== 'mercator' || !settings.showShips) {
|
|
||||||
remove();
|
remove();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibility = overlays.shipLabels ? 'visible' : 'none';
|
|
||||||
|
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point>[] = [];
|
|
||||||
for (const t of shipData) {
|
|
||||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
|
||||||
const isTarget = !!legacy;
|
|
||||||
const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi;
|
|
||||||
const isPinnedHighlight = shipHighlightSet.has(t.mmsi);
|
|
||||||
if (!isTarget && !isSelected && !isPinnedHighlight) continue;
|
|
||||||
|
|
||||||
const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim();
|
|
||||||
if (!labelName) continue;
|
|
||||||
|
|
||||||
features.push({
|
|
||||||
type: 'Feature',
|
|
||||||
id: `ship-label-${t.mmsi}`,
|
|
||||||
geometry: { type: 'Point', coordinates: [t.lon, t.lat] },
|
|
||||||
properties: {
|
|
||||||
mmsi: t.mmsi,
|
|
||||||
labelName,
|
|
||||||
selected: isSelected ? 1 : 0,
|
|
||||||
highlighted: isPinnedHighlight ? 1 : 0,
|
|
||||||
permitted: isTarget ? 1 : 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fc: GeoJSON.FeatureCollection<GeoJSON.Point> = { type: 'FeatureCollection', features };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
|
||||||
if (existing) existing.setData(fc);
|
|
||||||
else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Ship label source setup failed:', e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[];
|
|
||||||
|
|
||||||
if (!map.getLayer(layerId)) {
|
|
||||||
try {
|
|
||||||
map.addLayer(
|
|
||||||
{
|
|
||||||
id: layerId,
|
|
||||||
type: 'symbol',
|
|
||||||
source: srcId,
|
|
||||||
minzoom: 7,
|
|
||||||
filter: filter as never,
|
|
||||||
layout: {
|
|
||||||
visibility,
|
|
||||||
'symbol-placement': 'point',
|
|
||||||
'text-field': ['get', 'labelName'] as never,
|
|
||||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
|
|
||||||
'text-anchor': 'top',
|
|
||||||
'text-offset': [0, 1.1],
|
|
||||||
'text-padding': 2,
|
|
||||||
'text-allow-overlap': false,
|
|
||||||
'text-ignore-placement': false,
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'text-color': [
|
|
||||||
'case',
|
|
||||||
['==', ['get', 'selected'], 1],
|
|
||||||
'rgba(14,234,255,0.95)',
|
|
||||||
['==', ['get', 'highlighted'], 1],
|
|
||||||
'rgba(245,158,11,0.95)',
|
|
||||||
'rgba(226,232,240,0.92)',
|
|
||||||
] as never,
|
|
||||||
'text-halo-color': 'rgba(2,6,23,0.85)',
|
|
||||||
'text-halo-width': 1.2,
|
|
||||||
'text-halo-blur': 0.8,
|
|
||||||
},
|
|
||||||
} as unknown as LayerSpecification,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Ship label layer add failed:', e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
map.setLayoutProperty(layerId, 'visibility', visibility);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kickRepaint(map);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = onMapStyleReady(map, ensure);
|
const stop = onMapStyleReady(map, ensure);
|
||||||
return () => {
|
return () => {
|
||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
}, [
|
}, [mapSyncEpoch]);
|
||||||
projection,
|
|
||||||
settings.showShips,
|
|
||||||
overlays.shipLabels,
|
|
||||||
shipData,
|
|
||||||
legacyHits,
|
|
||||||
selectedMmsi,
|
|
||||||
shipHighlightSet,
|
|
||||||
mapSyncEpoch,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type maplibregl from 'maplibre-gl';
|
|||||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
import {
|
import {
|
||||||
@ -26,7 +27,14 @@ import {
|
|||||||
import { clampNumber } from '../lib/geometry';
|
import { clampNumber } from '../lib/geometry';
|
||||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
|
|
||||||
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label) */
|
// ── Alarm pulse animation constants ──
|
||||||
|
const ALARM_PULSE_R_MIN = 8;
|
||||||
|
const ALARM_PULSE_R_MAX = 14;
|
||||||
|
const ALARM_PULSE_R_HOVER_MIN = 12;
|
||||||
|
const ALARM_PULSE_R_HOVER_MAX = 18;
|
||||||
|
const ALARM_PULSE_PERIOD_MS = 1500;
|
||||||
|
|
||||||
|
/** Globe 모드 선박 아이콘 레이어 (halo, outline, symbol, label, alarm pulse, alarm badge) */
|
||||||
export function useGlobeShipLayers(
|
export function useGlobeShipLayers(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
projectionBusyRef: MutableRefObject<boolean>,
|
projectionBusyRef: MutableRefObject<boolean>,
|
||||||
@ -41,14 +49,17 @@ export function useGlobeShipLayers(
|
|||||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||||
mapSyncEpoch: number;
|
mapSyncEpoch: number;
|
||||||
onGlobeShipsReady?: (ready: boolean) => void;
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
projection, settings, shipData, overlays, legacyHits,
|
projection, settings, shipData, overlays, legacyHits,
|
||||||
selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
|
selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
|
||||||
|
alarmMmsiMap,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const epochRef = useRef(-1);
|
const epochRef = useRef(-1);
|
||||||
|
const breatheRafRef = useRef<number>(0);
|
||||||
|
|
||||||
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
||||||
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
||||||
@ -57,7 +68,9 @@ export function useGlobeShipLayers(
|
|||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: shipData.map((t) => {
|
features: shipData.map((t) => {
|
||||||
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
const legacy = legacyHits?.get(t.mmsi) ?? null;
|
||||||
const labelName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
const alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null;
|
||||||
|
const baseName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '';
|
||||||
|
const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
||||||
const heading = getDisplayHeading({
|
const heading = getDisplayHeading({
|
||||||
cog: t.cog,
|
cog: t.cog,
|
||||||
heading: t.heading,
|
heading: t.heading,
|
||||||
@ -106,11 +119,45 @@ export function useGlobeShipLayers(
|
|||||||
highlighted: highlighted ? 1 : 0,
|
highlighted: highlighted ? 1 : 0,
|
||||||
permitted: legacy ? 1 : 0,
|
permitted: legacy ? 1 : 0,
|
||||||
code: legacy?.shipCode || '',
|
code: legacy?.shipCode || '',
|
||||||
|
alarmed: alarmKind ? 1 : 0,
|
||||||
|
alarmKind: alarmKind ?? '',
|
||||||
|
alarmBadgeLabel: alarmKind ? ALARM_BADGE[alarmKind].label : '',
|
||||||
|
alarmBadgeColor: alarmKind ? ALARM_BADGE[alarmKind].color : '#000',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi]);
|
}, [shipData, legacyHits, selectedMmsi, isBaseHighlightedMmsi, alarmMmsiMap]);
|
||||||
|
|
||||||
|
// Alarm-only GeoJSON — separate source to avoid badge symbol re-placement
|
||||||
|
// when the main ship source updates (position polling)
|
||||||
|
const alarmGeoJson = useMemo((): GeoJSON.FeatureCollection<GeoJSON.Point> => {
|
||||||
|
if (!alarmMmsiMap || alarmMmsiMap.size === 0) {
|
||||||
|
return { type: 'FeatureCollection', features: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: shipData
|
||||||
|
.filter((t) => alarmMmsiMap.has(t.mmsi))
|
||||||
|
.map((t) => {
|
||||||
|
const alarmKind = alarmMmsiMap.get(t.mmsi)!;
|
||||||
|
const selected = t.mmsi === selectedMmsi;
|
||||||
|
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [t.lon, t.lat] },
|
||||||
|
properties: {
|
||||||
|
mmsi: t.mmsi,
|
||||||
|
alarmed: 1,
|
||||||
|
alarmBadgeLabel: ALARM_BADGE[alarmKind].label,
|
||||||
|
alarmBadgeColor: ALARM_BADGE[alarmKind].color,
|
||||||
|
selected: selected ? 1 : 0,
|
||||||
|
highlighted: highlighted ? 1 : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}, [shipData, alarmMmsiMap, selectedMmsi, isBaseHighlightedMmsi]);
|
||||||
|
|
||||||
// Ships in globe mode
|
// Ships in globe mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -120,16 +167,19 @@ export function useGlobeShipLayers(
|
|||||||
const imgId = 'ship-globe-icon';
|
const imgId = 'ship-globe-icon';
|
||||||
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
|
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
|
||||||
const srcId = 'ships-globe-src';
|
const srcId = 'ships-globe-src';
|
||||||
|
const alarmSrcId = 'ships-globe-alarm-src';
|
||||||
const haloId = 'ships-globe-halo';
|
const haloId = 'ships-globe-halo';
|
||||||
const outlineId = 'ships-globe-outline';
|
const outlineId = 'ships-globe-outline';
|
||||||
const symbolLiteId = 'ships-globe-lite';
|
const symbolLiteId = 'ships-globe-lite';
|
||||||
const symbolId = 'ships-globe';
|
const symbolId = 'ships-globe';
|
||||||
const labelId = 'ships-globe-label';
|
const labelId = 'ships-globe-label';
|
||||||
|
const pulseId = 'ships-globe-alarm-pulse';
|
||||||
|
const badgeId = 'ships-globe-alarm-badge';
|
||||||
|
|
||||||
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||||
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||||
const hide = () => {
|
const hide = () => {
|
||||||
for (const id of [labelId, symbolId, symbolLiteId, outlineId, haloId]) {
|
for (const id of [badgeId, labelId, symbolId, symbolLiteId, pulseId, outlineId, haloId]) {
|
||||||
guardedSetVisibility(map, id, 'none');
|
guardedSetVisibility(map, id, 'none');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -158,7 +208,7 @@ export function useGlobeShipLayers(
|
|||||||
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
|
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
|
||||||
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
|
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
|
||||||
if (changed) {
|
if (changed) {
|
||||||
for (const id of [haloId, outlineId, symbolLiteId, symbolId]) {
|
for (const id of [haloId, outlineId, pulseId, symbolLiteId, symbolId, badgeId]) {
|
||||||
guardedSetVisibility(map, id, visibility);
|
guardedSetVisibility(map, id, visibility);
|
||||||
}
|
}
|
||||||
if (projection === 'globe') kickRepaint(map);
|
if (projection === 'globe') kickRepaint(map);
|
||||||
@ -196,6 +246,15 @@ export function useGlobeShipLayers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alarm source — isolated from main source for stable badge rendering
|
||||||
|
try {
|
||||||
|
const existingAlarm = map.getSource(alarmSrcId) as GeoJSONSource | undefined;
|
||||||
|
if (existingAlarm) existingAlarm.setData(alarmGeoJson);
|
||||||
|
else map.addSource(alarmSrcId, { type: 'geojson', data: alarmGeoJson } as GeoJSONSourceSpecification);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Alarm source setup failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
const before = undefined;
|
const before = undefined;
|
||||||
const priorityFilter = [
|
const priorityFilter = [
|
||||||
'any',
|
'any',
|
||||||
@ -223,9 +282,11 @@ export function useGlobeShipLayers(
|
|||||||
'case',
|
'case',
|
||||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
|
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
|
||||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
|
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
|
||||||
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112,
|
||||||
['==', ['get', 'permitted'], 1], 110,
|
['==', ['get', 'permitted'], 1], 110,
|
||||||
['==', ['get', 'selected'], 1], 60,
|
['==', ['get', 'selected'], 1], 60,
|
||||||
['==', ['get', 'highlighted'], 1], 55,
|
['==', ['get', 'highlighted'], 1], 55,
|
||||||
|
['==', ['get', 'alarmed'], 1], 22,
|
||||||
20,
|
20,
|
||||||
] as never,
|
] as never,
|
||||||
},
|
},
|
||||||
@ -279,9 +340,11 @@ export function useGlobeShipLayers(
|
|||||||
'case',
|
'case',
|
||||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
|
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
|
||||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
|
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
|
||||||
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122,
|
||||||
['==', ['get', 'permitted'], 1], 120,
|
['==', ['get', 'permitted'], 1], 120,
|
||||||
['==', ['get', 'selected'], 1], 70,
|
['==', ['get', 'selected'], 1], 70,
|
||||||
['==', ['get', 'highlighted'], 1], 65,
|
['==', ['get', 'highlighted'], 1], 65,
|
||||||
|
['==', ['get', 'alarmed'], 1], 32,
|
||||||
30,
|
30,
|
||||||
] as never,
|
] as never,
|
||||||
},
|
},
|
||||||
@ -293,6 +356,31 @@ export function useGlobeShipLayers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alarm pulse circle (above outline, below ship icons)
|
||||||
|
// Uses separate alarm source for stable rendering
|
||||||
|
if (!map.getLayer(pulseId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: pulseId,
|
||||||
|
type: 'circle',
|
||||||
|
source: alarmSrcId,
|
||||||
|
filter: ['==', ['get', 'alarmed'], 1] as never,
|
||||||
|
layout: { visibility },
|
||||||
|
paint: {
|
||||||
|
'circle-radius': ALARM_PULSE_R_MIN,
|
||||||
|
'circle-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
||||||
|
'circle-opacity': 0.35,
|
||||||
|
'circle-stroke-width': 0,
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship alarm pulse layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!map.getLayer(symbolLiteId)) {
|
if (!map.getLayer(symbolLiteId)) {
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
@ -376,9 +464,11 @@ export function useGlobeShipLayers(
|
|||||||
'case',
|
'case',
|
||||||
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
|
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
|
||||||
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
|
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
|
||||||
|
['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 132,
|
||||||
['==', ['get', 'permitted'], 1], 130,
|
['==', ['get', 'permitted'], 1], 130,
|
||||||
['==', ['get', 'selected'], 1], 80,
|
['==', ['get', 'selected'], 1], 80,
|
||||||
['==', ['get', 'highlighted'], 1], 75,
|
['==', ['get', 'highlighted'], 1], 75,
|
||||||
|
['==', ['get', 'alarmed'], 1], 47,
|
||||||
45,
|
45,
|
||||||
] as never,
|
] as never,
|
||||||
'icon-image': [
|
'icon-image': [
|
||||||
@ -475,6 +565,39 @@ export function useGlobeShipLayers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alarm badge symbol (above labels)
|
||||||
|
// Uses separate alarm source for stable rendering
|
||||||
|
if (!map.getLayer(badgeId)) {
|
||||||
|
try {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: badgeId,
|
||||||
|
type: 'symbol',
|
||||||
|
source: alarmSrcId,
|
||||||
|
filter: ['==', ['get', 'alarmed'], 1] as never,
|
||||||
|
layout: {
|
||||||
|
visibility,
|
||||||
|
'text-field': ['get', 'alarmBadgeLabel'] as never,
|
||||||
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
|
'text-size': 11,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
'text-ignore-placement': true,
|
||||||
|
'text-anchor': 'center',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#ffffff',
|
||||||
|
'text-halo-color': ['coalesce', ['get', 'alarmBadgeColor'], '#6b7280'] as never,
|
||||||
|
'text-halo-width': 6,
|
||||||
|
'text-translate': [12, -12],
|
||||||
|
},
|
||||||
|
} as unknown as LayerSpecification,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ship alarm badge layer add failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||||
onGlobeShipsReady?.(true);
|
onGlobeShipsReady?.(true);
|
||||||
if (projection === 'globe') {
|
if (projection === 'globe') {
|
||||||
@ -492,10 +615,50 @@ export function useGlobeShipLayers(
|
|||||||
settings.showShips,
|
settings.showShips,
|
||||||
overlays.shipLabels,
|
overlays.shipLabels,
|
||||||
globeShipGeoJson,
|
globeShipGeoJson,
|
||||||
|
alarmGeoJson,
|
||||||
selectedMmsi,
|
selectedMmsi,
|
||||||
isBaseHighlightedMmsi,
|
isBaseHighlightedMmsi,
|
||||||
mapSyncEpoch,
|
mapSyncEpoch,
|
||||||
reorderGlobeFeatureLayers,
|
reorderGlobeFeatureLayers,
|
||||||
onGlobeShipsReady,
|
onGlobeShipsReady,
|
||||||
|
alarmMmsiMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Alarm pulse breathing animation (rAF)
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || projection !== 'globe' || !alarmMmsiMap || alarmMmsiMap.size === 0) {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = (Math.sin(Date.now() / ALARM_PULSE_PERIOD_MS * Math.PI * 2) + 1) / 2;
|
||||||
|
const normalR = ALARM_PULSE_R_MIN + t * (ALARM_PULSE_R_MAX - ALARM_PULSE_R_MIN);
|
||||||
|
const hoverR = ALARM_PULSE_R_HOVER_MIN + t * (ALARM_PULSE_R_HOVER_MAX - ALARM_PULSE_R_HOVER_MIN);
|
||||||
|
try {
|
||||||
|
if (map.getLayer('ships-globe-alarm-pulse')) {
|
||||||
|
map.setPaintProperty('ships-globe-alarm-pulse', 'circle-radius', [
|
||||||
|
'case',
|
||||||
|
['any', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'selected'], 1]],
|
||||||
|
hoverR,
|
||||||
|
normalR,
|
||||||
|
] as never);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
breatheRafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => {
|
||||||
|
if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current);
|
||||||
|
breatheRafRef.current = 0;
|
||||||
|
};
|
||||||
|
}, [projection, alarmMmsiMap]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { MutableRefObject } from 'react';
|
|||||||
import type maplibregl from 'maplibre-gl';
|
import type maplibregl from 'maplibre-gl';
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import type { LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { Map3DSettings, MapProjectionId } from '../types';
|
import type { Map3DSettings, MapProjectionId } from '../types';
|
||||||
import { useGlobeShipLabels } from './useGlobeShipLabels';
|
import { useGlobeShipLabels } from './useGlobeShipLabels';
|
||||||
@ -31,6 +32,7 @@ export function useGlobeShips(
|
|||||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||||
onGlobeShipsReady?: (ready: boolean) => void;
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Mercator 모드 선명 라벨
|
// Mercator 모드 선명 라벨
|
||||||
@ -43,6 +45,7 @@ export function useGlobeShips(
|
|||||||
legacyHits: opts.legacyHits,
|
legacyHits: opts.legacyHits,
|
||||||
selectedMmsi: opts.selectedMmsi,
|
selectedMmsi: opts.selectedMmsi,
|
||||||
mapSyncEpoch: opts.mapSyncEpoch,
|
mapSyncEpoch: opts.mapSyncEpoch,
|
||||||
|
alarmMmsiMap: opts.alarmMmsiMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Globe 모드 선박 아이콘 레이어
|
// Globe 모드 선박 아이콘 레이어
|
||||||
@ -56,6 +59,7 @@ export function useGlobeShips(
|
|||||||
isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi,
|
isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi,
|
||||||
mapSyncEpoch: opts.mapSyncEpoch,
|
mapSyncEpoch: opts.mapSyncEpoch,
|
||||||
onGlobeShipsReady: opts.onGlobeShipsReady,
|
onGlobeShipsReady: opts.onGlobeShipsReady,
|
||||||
|
alarmMmsiMap: opts.alarmMmsiMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Globe 호버 오버레이 + 클릭 선택
|
// Globe 호버 오버레이 + 클릭 선택
|
||||||
|
|||||||
@ -112,9 +112,11 @@ export function useProjectionToggle(
|
|||||||
'predict-vectors-hl',
|
'predict-vectors-hl',
|
||||||
'ships-globe-halo',
|
'ships-globe-halo',
|
||||||
'ships-globe-outline',
|
'ships-globe-outline',
|
||||||
|
'ships-globe-alarm-pulse',
|
||||||
'ships-globe-lite',
|
'ships-globe-lite',
|
||||||
'ships-globe',
|
'ships-globe',
|
||||||
'ships-globe-label',
|
'ships-globe-label',
|
||||||
|
'ships-globe-alarm-badge',
|
||||||
'ships-globe-hover-halo',
|
'ships-globe-hover-halo',
|
||||||
'ships-globe-hover-outline',
|
'ships-globe-hover-outline',
|
||||||
'ships-globe-hover',
|
'ships-globe-hover',
|
||||||
@ -215,6 +217,13 @@ export function useProjectionToggle(
|
|||||||
quietMercatorOverlays();
|
quietMercatorOverlays();
|
||||||
} else {
|
} else {
|
||||||
quietGlobeDeckLayer();
|
quietGlobeDeckLayer();
|
||||||
|
quietMercatorOverlays();
|
||||||
|
// Globe custom layer를 맵에서 분리 — setProjection() 중 render 콜백에서
|
||||||
|
// stale WebGL 자원(uniform buffer 등) 참조를 방지
|
||||||
|
const gl = globeDeckLayerRef.current;
|
||||||
|
if (gl?.id) {
|
||||||
|
try { if (map.getLayer(gl.id)) map.removeLayer(gl.id); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
||||||
import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers';
|
import { IconLayer, LineLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import type { PickingInfo } from '@deck.gl/core';
|
import type { PickingInfo } from '@deck.gl/core';
|
||||||
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
||||||
|
import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
import type { FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types';
|
||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { DashSeg, PairRangeCircle } from '../types';
|
import type { DashSeg, PairRangeCircle } from '../types';
|
||||||
@ -80,6 +81,10 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect
|
|||||||
showShips: boolean;
|
showShips: boolean;
|
||||||
selectedMmsi: number | null;
|
selectedMmsi: number | null;
|
||||||
shipHighlightSet: Set<number>;
|
shipHighlightSet: Set<number>;
|
||||||
|
alarmTargets?: AisTarget[];
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
|
alarmPulseRadius?: number;
|
||||||
|
alarmPulseHoverRadius?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
|
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
|
||||||
@ -118,10 +123,11 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
|
|
||||||
/* ─ pair range ─ */
|
/* ─ pair range ─ */
|
||||||
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
|
if (ctx.overlays.pairRange && ctx.pairRanges.length > 0) {
|
||||||
layers.push(
|
const validRanges = ctx.pairRanges.filter((d) => d.center && isFinite(d.center[0]) && isFinite(d.center[1]) && isFinite(d.radiusNm));
|
||||||
|
if (validRanges.length > 0) layers.push(
|
||||||
new ScatterplotLayer<PairRangeCircle>({
|
new ScatterplotLayer<PairRangeCircle>({
|
||||||
id: 'pair-range',
|
id: 'pair-range',
|
||||||
data: ctx.pairRanges,
|
data: validRanges,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
billboard: false,
|
billboard: false,
|
||||||
parameters: overlayParams,
|
parameters: overlayParams,
|
||||||
@ -131,7 +137,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
getRadius: (d) => d.radiusNm * 1852,
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
radiusMinPixels: 10,
|
radiusMinPixels: 10,
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
getLineWidth: () => 1,
|
getLineWidth: () => 1.8,
|
||||||
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
||||||
getPosition: (d) => d.center,
|
getPosition: (d) => d.center,
|
||||||
onHover: (info) => {
|
onHover: (info) => {
|
||||||
@ -168,7 +174,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
getSourcePosition: (d) => d.from,
|
getSourcePosition: (d) => d.from,
|
||||||
getTargetPosition: (d) => d.to,
|
getTargetPosition: (d) => d.to,
|
||||||
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
|
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
|
||||||
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
getWidth: (d) => (d.warn ? 3.5 : 2.5),
|
||||||
widthUnits: 'pixels',
|
widthUnits: 'pixels',
|
||||||
onHover: (info) => {
|
onHover: (info) => {
|
||||||
if (!info.object) { clearDeckHover(); return; }
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
@ -204,7 +210,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
getSourcePosition: (d) => d.from,
|
getSourcePosition: (d) => d.from,
|
||||||
getTargetPosition: (d) => d.to,
|
getTargetPosition: (d) => d.to,
|
||||||
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
||||||
getWidth: () => 1.3,
|
getWidth: () => 2.2,
|
||||||
widthUnits: 'pixels',
|
widthUnits: 'pixels',
|
||||||
onHover: (info) => {
|
onHover: (info) => {
|
||||||
if (!info.object) { clearDeckHover(); return; }
|
if (!info.object) { clearDeckHover(); return; }
|
||||||
@ -245,7 +251,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
radiusUnits: 'meters',
|
radiusUnits: 'meters',
|
||||||
getRadius: (d) => d.radiusNm * 1852,
|
getRadius: (d) => d.radiusNm * 1852,
|
||||||
lineWidthUnits: 'pixels',
|
lineWidthUnits: 'pixels',
|
||||||
getLineWidth: () => 1.1,
|
getLineWidth: () => 2.0,
|
||||||
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
||||||
getPosition: (d) => d.center,
|
getPosition: (d) => d.center,
|
||||||
onHover: (info) => {
|
onHover: (info) => {
|
||||||
@ -403,17 +409,17 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
|
|
||||||
/* ─ interactive overlays ─ */
|
/* ─ interactive overlays ─ */
|
||||||
if (ctx.pairRangesInteractive.length > 0) {
|
if (ctx.pairRangesInteractive.length > 0) {
|
||||||
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.2, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
layers.push(new ScatterplotLayer<PairRangeCircle>({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.8, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center }));
|
||||||
}
|
}
|
||||||
if (ctx.pairLinksInteractive.length > 0) {
|
if (ctx.pairLinksInteractive.length > 0) {
|
||||||
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' }));
|
layers.push(new LineLayer<PairLink>({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 4.5, widthUnits: 'pixels' }));
|
||||||
}
|
}
|
||||||
if (ctx.fcLinesInteractive.length > 0) {
|
if (ctx.fcLinesInteractive.length > 0) {
|
||||||
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' }));
|
layers.push(new LineLayer<DashSeg>({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 3.2, widthUnits: 'pixels' }));
|
||||||
}
|
}
|
||||||
if (ctx.fleetCirclesInteractive.length > 0) {
|
if (ctx.fleetCirclesInteractive.length > 0) {
|
||||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL }));
|
||||||
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 1.8, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
layers.push(new ScatterplotLayer<FleetCircle>({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─ legacy overlay (highlight/selected) ─ */
|
/* ─ legacy overlay (highlight/selected) ─ */
|
||||||
@ -426,6 +432,103 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
|||||||
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } }));
|
layers.push(new IconLayer<AisTarget>({ id: 'ships-overlay-target', data: shipOverlayTargetData2, pickable: false, billboard: false, parameters: overlayParams, iconAtlas: getCachedShipIcon(), iconMapping: SHIP_ICON_MAPPING, getIcon: () => 'ship', getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => -getDisplayHeading({ cog: d.cog, heading: d.heading }), sizeUnits: 'pixels', getSize: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (ctx.shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!ctx.shipHighlightSet.has(d.mmsi) && !(ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, ctx.selectedMmsi, ctx.legacyHits?.get(d.mmsi)?.shipCode ?? null, ctx.shipHighlightSet); } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─ ship name labels (Mercator) ─ */
|
||||||
|
if (ctx.showShips && ctx.overlays.shipLabels) {
|
||||||
|
const labelData: AisTarget[] = [];
|
||||||
|
for (const t of ctx.shipLayerData) {
|
||||||
|
const isTarget = ctx.legacyHits?.has(t.mmsi) ?? false;
|
||||||
|
const isSelected = ctx.selectedMmsi != null && t.mmsi === ctx.selectedMmsi;
|
||||||
|
const isHighlighted = ctx.shipHighlightSet.has(t.mmsi);
|
||||||
|
if (isTarget || isSelected || isHighlighted) labelData.push(t);
|
||||||
|
}
|
||||||
|
if (labelData.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<AisTarget>({
|
||||||
|
id: 'ship-labels',
|
||||||
|
data: labelData,
|
||||||
|
pickable: false,
|
||||||
|
billboard: true,
|
||||||
|
getText: (d) => {
|
||||||
|
const legacy = ctx.legacyHits?.get(d.mmsi);
|
||||||
|
const baseName = (legacy?.shipNameCn || legacy?.shipNameRoman || d.name || '').trim();
|
||||||
|
if (!baseName) return '';
|
||||||
|
const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null;
|
||||||
|
return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
getColor: (d) => {
|
||||||
|
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242];
|
||||||
|
if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 242];
|
||||||
|
return [226, 232, 240, 234];
|
||||||
|
},
|
||||||
|
getSize: 11,
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Noto Sans KR, sans-serif',
|
||||||
|
characterSet: 'auto',
|
||||||
|
getPixelOffset: [0, 16],
|
||||||
|
getTextAnchor: 'middle',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [2, 6, 23, 217],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─ alarm pulse + badge ─ */
|
||||||
|
const alarmTargets = ctx.alarmTargets ?? [];
|
||||||
|
const alarmMap = ctx.alarmMmsiMap;
|
||||||
|
if (ctx.showShips && alarmMap && alarmMap.size > 0 && alarmTargets.length > 0) {
|
||||||
|
const pulseR = ctx.alarmPulseRadius ?? 8;
|
||||||
|
const pulseHR = ctx.alarmPulseHoverRadius ?? 12;
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<AisTarget>({
|
||||||
|
id: 'alarm-pulse',
|
||||||
|
data: alarmTargets,
|
||||||
|
pickable: false,
|
||||||
|
billboard: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
filled: true,
|
||||||
|
stroked: false,
|
||||||
|
radiusUnits: 'pixels',
|
||||||
|
getRadius: (d) => {
|
||||||
|
const isHover = (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) || ctx.shipHighlightSet.has(d.mmsi);
|
||||||
|
return isHover ? pulseHR : pulseR;
|
||||||
|
},
|
||||||
|
getFillColor: (d) => {
|
||||||
|
const kind = alarmMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
updateTriggers: { getRadius: [pulseR, pulseHR, ctx.selectedMmsi, ctx.shipHighlightSet] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<AisTarget>({
|
||||||
|
id: 'alarm-badge',
|
||||||
|
data: alarmTargets,
|
||||||
|
pickable: false,
|
||||||
|
parameters: overlayParams,
|
||||||
|
getText: (d) => {
|
||||||
|
const kind = alarmMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].label : '';
|
||||||
|
},
|
||||||
|
getPosition: (d) => [d.lon, d.lat] as [number, number],
|
||||||
|
getColor: [255, 255, 255, 255],
|
||||||
|
getBackgroundColor: (d) => {
|
||||||
|
const kind = alarmMap.get(d.mmsi);
|
||||||
|
return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 200] as [number, number, number, number];
|
||||||
|
},
|
||||||
|
background: true,
|
||||||
|
backgroundPadding: [3, 1],
|
||||||
|
getPixelOffset: [14, -14],
|
||||||
|
sizeUnits: 'pixels',
|
||||||
|
getSize: 10,
|
||||||
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, Noto Sans KR, sans-serif',
|
||||||
|
characterSet: 'auto',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return layers;
|
return layers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { SubcableGeoJson } from '../../entities/subcable/model/types';
|
|||||||
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
|
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
|
||||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||||
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
|
||||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||||
|
|
||||||
export type Map3DSettings = {
|
export type Map3DSettings = {
|
||||||
@ -69,6 +69,8 @@ export interface Map3DProps {
|
|||||||
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
||||||
onCloseTrackMenu?: () => void;
|
onCloseTrackMenu?: () => void;
|
||||||
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
|
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
|
||||||
|
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
|
||||||
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashSeg = {
|
export type DashSeg = {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user