develop #28
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 지연",
|
||||
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 { useTheme } from "../../shared/hooks";
|
||||
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
|
||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||
import { LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
|
||||
import type { DerivedLegacyVessel, LegacyAlarmKind } 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 { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
||||
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
|
||||
@ -36,6 +36,7 @@ import {
|
||||
deriveLegacyVessels,
|
||||
filterByShipCodes,
|
||||
} from "../../features/legacyDashboard/model/derive";
|
||||
import { MOCK_AIS_TARGETS, MOCK_LEGACY_ENTRIES } from "../../features/legacyDashboard/dev/mockOverlayData";
|
||||
import { useDashboardState } from "./useDashboardState";
|
||||
import type { Bbox } from "./useDashboardState";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
@ -62,6 +63,7 @@ export function DashboardPage() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const uid = user?.id ?? null;
|
||||
const isDevMode = user?.name?.includes('(DEV)') ?? false;
|
||||
|
||||
// ── Data fetching ──
|
||||
const { data: zones, error: zonesError } = useZones();
|
||||
@ -142,11 +144,19 @@ export function DashboardPage() {
|
||||
|
||||
// ── Derived data ──
|
||||
const targetsInScope = useMemo(() => {
|
||||
if (!useViewportFilter || !viewBbox) return targets;
|
||||
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
|
||||
}, [targets, useViewportFilter, viewBbox]);
|
||||
const base = (!useViewportFilter || !viewBbox)
|
||||
? targets
|
||||
: 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 legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
|
||||
|
||||
@ -195,6 +205,17 @@ export function DashboardPage() {
|
||||
return alarms.filter((a) => enabled.has(a.kind));
|
||||
}, [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 fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
|
||||
@ -348,6 +369,7 @@ export function DashboardPage() {
|
||||
onCloseTrackMenu={handleCloseTrackMenu}
|
||||
onOpenTrackMenu={handleOpenTrackMenu}
|
||||
onMapReady={handleMapReady}
|
||||
alarmMmsiMap={alarmMmsiMap}
|
||||
/>
|
||||
<GlobalTrackReplayPanel />
|
||||
<WeatherPanel
|
||||
|
||||
@ -81,6 +81,7 @@ export function Map3D({
|
||||
onCloseTrackMenu,
|
||||
onOpenTrackMenu,
|
||||
onMapReady,
|
||||
alarmMmsiMap,
|
||||
}: Props) {
|
||||
// ── Shared refs ──────────────────────────────────────────────────────
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -563,7 +564,7 @@ export function Map3D({
|
||||
shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch,
|
||||
onSelectMmsi, onToggleHighlightMmsi, targets: shipLayerData, overlays,
|
||||
legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier,
|
||||
onGlobeShipsReady,
|
||||
onGlobeShipsReady, alarmMmsiMap,
|
||||
},
|
||||
);
|
||||
|
||||
@ -597,7 +598,7 @@ export function Map3D({
|
||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||
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 { type PickingInfo } from '@deck.gl/core';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/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 { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types';
|
||||
@ -67,6 +69,7 @@ export function useDeckLayers(
|
||||
onToggleHighlightMmsi?: (mmsi: number) => void;
|
||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||
projectionRef: MutableRefObject<MapProjectionId>;
|
||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
@ -79,7 +82,7 @@ export function useDeckLayers(
|
||||
setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState,
|
||||
toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier,
|
||||
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
|
||||
ensureMercatorOverlay, projectionRef,
|
||||
ensureMercatorOverlay, projectionRef, alarmMmsiMap,
|
||||
} = opts;
|
||||
|
||||
const legacyTargets = useMemo(() => {
|
||||
@ -99,6 +102,14 @@ export function useDeckLayers(
|
||||
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@ -147,11 +158,16 @@ export function useDeckLayers(
|
||||
onSelectMmsi,
|
||||
onToggleHighlightMmsi,
|
||||
onDeckSelectOrHighlight,
|
||||
alarmTargets,
|
||||
alarmMmsiMap,
|
||||
alarmPulseRadius: 8,
|
||||
alarmPulseHoverRadius: 12,
|
||||
});
|
||||
|
||||
const normalizedBaseLayers = sanitizeDeckLayerList(layers);
|
||||
const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers);
|
||||
const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]);
|
||||
mercatorLayersRef.current = normalizedLayers;
|
||||
const deckProps = {
|
||||
layers: normalizedLayers,
|
||||
getTooltip: (info: PickingInfo) => {
|
||||
@ -239,6 +255,7 @@ export function useDeckLayers(
|
||||
overlays.pairLines,
|
||||
overlays.fcLines,
|
||||
overlays.fleetCircles,
|
||||
overlays.shipLabels,
|
||||
settings.showDensity,
|
||||
settings.showShips,
|
||||
trackReplayDeckLayers,
|
||||
@ -252,8 +269,73 @@ export function useDeckLayers(
|
||||
toFleetMmsiList,
|
||||
touchDeckHoverState,
|
||||
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
|
||||
useEffect(() => {
|
||||
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 { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types';
|
||||
@ -25,6 +25,16 @@ import { circleRingLngLat } from '../lib/geometry';
|
||||
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||
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 오버레이 */
|
||||
export function useGlobeFcFleetOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -45,6 +55,7 @@ export function useGlobeFcFleetOverlay(
|
||||
overlays, fcLinks, fleetCircles, projection, mapSyncEpoch,
|
||||
hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList,
|
||||
} = opts;
|
||||
const breatheRafRef = useRef<number>(0);
|
||||
|
||||
// FC lines
|
||||
useEffect(() => {
|
||||
@ -119,7 +130,7 @@ export function useGlobeFcFleetOverlay(
|
||||
FC_LINE_SUSPICIOUS_ML,
|
||||
FC_LINE_NORMAL_ML,
|
||||
] 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,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
@ -244,7 +255,7 @@ export function useGlobeFcFleetOverlay(
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
paint: {
|
||||
'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,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
@ -327,7 +338,7 @@ export function useGlobeFcFleetOverlay(
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'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 {
|
||||
@ -337,7 +348,7 @@ export function useGlobeFcFleetOverlay(
|
||||
try {
|
||||
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-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 {
|
||||
// ignore
|
||||
@ -353,4 +364,55 @@ export function useGlobeFcFleetOverlay(
|
||||
stop();
|
||||
};
|
||||
}, [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 { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { PairLink } from '../../../features/legacyDashboard/model/types';
|
||||
@ -16,6 +16,17 @@ import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||
import { circleRingLngLat } from '../lib/geometry';
|
||||
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 오버레이 */
|
||||
export function useGlobePairOverlay(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
@ -30,6 +41,7 @@ export function useGlobePairOverlay(
|
||||
},
|
||||
) {
|
||||
const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts;
|
||||
const breatheRafRef = useRef<number>(0);
|
||||
|
||||
// Pair lines
|
||||
useEffect(() => {
|
||||
@ -96,9 +108,9 @@ export function useGlobePairOverlay(
|
||||
] as never,
|
||||
'line-width': [
|
||||
'case',
|
||||
['==', ['get', 'highlighted'], 1], 2.8,
|
||||
['boolean', ['get', 'warn'], false], 2.2,
|
||||
1.4,
|
||||
['==', ['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,
|
||||
},
|
||||
@ -197,7 +209,11 @@ export function useGlobePairOverlay(
|
||||
id: layerId,
|
||||
type: 'line',
|
||||
source: srcId,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' },
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
visibility: 'visible',
|
||||
},
|
||||
paint: {
|
||||
'line-color': [
|
||||
'case',
|
||||
@ -207,7 +223,7 @@ export function useGlobePairOverlay(
|
||||
PAIR_RANGE_WARN_ML,
|
||||
PAIR_RANGE_NORMAL_ML,
|
||||
] 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,
|
||||
},
|
||||
} as unknown as LayerSpecification,
|
||||
@ -230,7 +246,7 @@ export function useGlobePairOverlay(
|
||||
};
|
||||
}, [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
|
||||
const updatePairPaintStates = useCallback(() => {
|
||||
if (projection !== 'globe' || projectionBusyRef.current) return;
|
||||
@ -249,7 +265,7 @@ export function useGlobePairOverlay(
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'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 {
|
||||
@ -264,7 +280,7 @@ export function useGlobePairOverlay(
|
||||
);
|
||||
map.setPaintProperty(
|
||||
'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 {
|
||||
@ -281,4 +297,44 @@ export function useGlobePairOverlay(
|
||||
stop();
|
||||
};
|
||||
}, [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 type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/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 { 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(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
_projectionBusyRef: MutableRefObject<boolean>,
|
||||
opts: {
|
||||
projection: MapProjectionId;
|
||||
settings: Map3DSettings;
|
||||
@ -20,12 +23,10 @@ export function useGlobeShipLabels(
|
||||
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
||||
selectedMmsi: number | null;
|
||||
mapSyncEpoch: number;
|
||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, shipHighlightSet,
|
||||
overlays, legacyHits, selectedMmsi, mapSyncEpoch,
|
||||
} = opts;
|
||||
const { mapSyncEpoch } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@ -48,117 +49,16 @@ export function useGlobeShipLabels(
|
||||
};
|
||||
|
||||
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 (projection !== 'mercator' || !settings.showShips) {
|
||||
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);
|
||||
remove();
|
||||
};
|
||||
|
||||
const stop = onMapStyleReady(map, ensure);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [
|
||||
projection,
|
||||
settings.showShips,
|
||||
overlays.shipLabels,
|
||||
shipData,
|
||||
legacyHits,
|
||||
selectedMmsi,
|
||||
shipHighlightSet,
|
||||
mapSyncEpoch,
|
||||
]);
|
||||
}, [mapSyncEpoch]);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import type maplibregl from 'maplibre-gl';
|
||||
import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl';
|
||||
import type { AisTarget } from '../../../entities/aisTarget/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 { Map3DSettings, MapProjectionId } from '../types';
|
||||
import {
|
||||
@ -26,7 +27,14 @@ import {
|
||||
import { clampNumber } from '../lib/geometry';
|
||||
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(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
projectionBusyRef: MutableRefObject<boolean>,
|
||||
@ -41,14 +49,17 @@ export function useGlobeShipLayers(
|
||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||
mapSyncEpoch: number;
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
projection, settings, shipData, overlays, legacyHits,
|
||||
selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady,
|
||||
alarmMmsiMap,
|
||||
} = opts;
|
||||
|
||||
const epochRef = useRef(-1);
|
||||
const breatheRafRef = useRef<number>(0);
|
||||
|
||||
// Globe GeoJSON을 projection과 무관하게 항상 사전 계산
|
||||
// Globe 전환 시 즉시 사용 가능하도록 useMemo로 캐싱
|
||||
@ -57,7 +68,9 @@ export function useGlobeShipLayers(
|
||||
type: 'FeatureCollection',
|
||||
features: shipData.map((t) => {
|
||||
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({
|
||||
cog: t.cog,
|
||||
heading: t.heading,
|
||||
@ -106,11 +119,45 @@ export function useGlobeShipLayers(
|
||||
highlighted: highlighted ? 1 : 0,
|
||||
permitted: legacy ? 1 : 0,
|
||||
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
|
||||
useEffect(() => {
|
||||
@ -120,16 +167,19 @@ export function useGlobeShipLayers(
|
||||
const imgId = 'ship-globe-icon';
|
||||
const anchoredImgId = ANCHORED_SHIP_ICON_ID;
|
||||
const srcId = 'ships-globe-src';
|
||||
const alarmSrcId = 'ships-globe-alarm-src';
|
||||
const haloId = 'ships-globe-halo';
|
||||
const outlineId = 'ships-globe-outline';
|
||||
const symbolLiteId = 'ships-globe-lite';
|
||||
const symbolId = 'ships-globe';
|
||||
const labelId = 'ships-globe-label';
|
||||
const pulseId = 'ships-globe-alarm-pulse';
|
||||
const badgeId = 'ships-globe-alarm-badge';
|
||||
|
||||
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||
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');
|
||||
}
|
||||
};
|
||||
@ -158,7 +208,7 @@ export function useGlobeShipLayers(
|
||||
map.getLayoutProperty(symbolId, 'visibility') !== visibility ||
|
||||
map.getLayoutProperty(symbolLiteId, 'visibility') !== visibility;
|
||||
if (changed) {
|
||||
for (const id of [haloId, outlineId, symbolLiteId, symbolId]) {
|
||||
for (const id of [haloId, outlineId, pulseId, symbolLiteId, symbolId, badgeId]) {
|
||||
guardedSetVisibility(map, id, visibility);
|
||||
}
|
||||
if (projection === 'globe') kickRepaint(map);
|
||||
@ -196,6 +246,15 @@ export function useGlobeShipLayers(
|
||||
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 priorityFilter = [
|
||||
'any',
|
||||
@ -223,9 +282,11 @@ export function useGlobeShipLayers(
|
||||
'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,
|
||||
['==', ['get', 'permitted'], 1], 110,
|
||||
['==', ['get', 'selected'], 1], 60,
|
||||
['==', ['get', 'highlighted'], 1], 55,
|
||||
['==', ['get', 'alarmed'], 1], 22,
|
||||
20,
|
||||
] as never,
|
||||
},
|
||||
@ -279,9 +340,11 @@ export function useGlobeShipLayers(
|
||||
'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,
|
||||
['==', ['get', 'permitted'], 1], 120,
|
||||
['==', ['get', 'selected'], 1], 70,
|
||||
['==', ['get', 'highlighted'], 1], 65,
|
||||
['==', ['get', 'alarmed'], 1], 32,
|
||||
30,
|
||||
] 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)) {
|
||||
try {
|
||||
map.addLayer(
|
||||
@ -376,9 +464,11 @@ export function useGlobeShipLayers(
|
||||
'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,
|
||||
['==', ['get', 'permitted'], 1], 130,
|
||||
['==', ['get', 'selected'], 1], 80,
|
||||
['==', ['get', 'highlighted'], 1], 75,
|
||||
['==', ['get', 'alarmed'], 1], 47,
|
||||
45,
|
||||
] as never,
|
||||
'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과 무관 — 토글 버튼 활성화용)
|
||||
onGlobeShipsReady?.(true);
|
||||
if (projection === 'globe') {
|
||||
@ -492,10 +615,50 @@ export function useGlobeShipLayers(
|
||||
settings.showShips,
|
||||
overlays.shipLabels,
|
||||
globeShipGeoJson,
|
||||
alarmGeoJson,
|
||||
selectedMmsi,
|
||||
isBaseHighlightedMmsi,
|
||||
mapSyncEpoch,
|
||||
reorderGlobeFeatureLayers,
|
||||
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 { AisTarget } from '../../../entities/aisTarget/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 { Map3DSettings, MapProjectionId } from '../types';
|
||||
import { useGlobeShipLabels } from './useGlobeShipLabels';
|
||||
@ -31,6 +32,7 @@ export function useGlobeShips(
|
||||
isBaseHighlightedMmsi: (mmsi: number) => boolean;
|
||||
hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean;
|
||||
onGlobeShipsReady?: (ready: boolean) => void;
|
||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||
},
|
||||
) {
|
||||
// Mercator 모드 선명 라벨
|
||||
@ -43,6 +45,7 @@ export function useGlobeShips(
|
||||
legacyHits: opts.legacyHits,
|
||||
selectedMmsi: opts.selectedMmsi,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
alarmMmsiMap: opts.alarmMmsiMap,
|
||||
});
|
||||
|
||||
// Globe 모드 선박 아이콘 레이어
|
||||
@ -56,6 +59,7 @@ export function useGlobeShips(
|
||||
isBaseHighlightedMmsi: opts.isBaseHighlightedMmsi,
|
||||
mapSyncEpoch: opts.mapSyncEpoch,
|
||||
onGlobeShipsReady: opts.onGlobeShipsReady,
|
||||
alarmMmsiMap: opts.alarmMmsiMap,
|
||||
});
|
||||
|
||||
// Globe 호버 오버레이 + 클릭 선택
|
||||
|
||||
@ -112,9 +112,11 @@ export function useProjectionToggle(
|
||||
'predict-vectors-hl',
|
||||
'ships-globe-halo',
|
||||
'ships-globe-outline',
|
||||
'ships-globe-alarm-pulse',
|
||||
'ships-globe-lite',
|
||||
'ships-globe',
|
||||
'ships-globe-label',
|
||||
'ships-globe-alarm-badge',
|
||||
'ships-globe-hover-halo',
|
||||
'ships-globe-hover-outline',
|
||||
'ships-globe-hover',
|
||||
@ -215,6 +217,13 @@ export function useProjectionToggle(
|
||||
quietMercatorOverlays();
|
||||
} else {
|
||||
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 {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
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 { AisTarget } from '../../../entities/aisTarget/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 { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||
import type { DashSeg, PairRangeCircle } from '../types';
|
||||
@ -80,6 +81,10 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect
|
||||
showShips: boolean;
|
||||
selectedMmsi: number | null;
|
||||
shipHighlightSet: Set<number>;
|
||||
alarmTargets?: AisTarget[];
|
||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||
alarmPulseRadius?: number;
|
||||
alarmPulseHoverRadius?: number;
|
||||
}
|
||||
|
||||
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
|
||||
@ -118,10 +123,11 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
||||
|
||||
/* ─ pair range ─ */
|
||||
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>({
|
||||
id: 'pair-range',
|
||||
data: ctx.pairRanges,
|
||||
data: validRanges,
|
||||
pickable: true,
|
||||
billboard: false,
|
||||
parameters: overlayParams,
|
||||
@ -131,7 +137,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
radiusMinPixels: 10,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 1,
|
||||
getLineWidth: () => 1.8,
|
||||
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
||||
getPosition: (d) => d.center,
|
||||
onHover: (info) => {
|
||||
@ -168,7 +174,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
||||
getSourcePosition: (d) => d.from,
|
||||
getTargetPosition: (d) => d.to,
|
||||
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',
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
@ -204,7 +210,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
||||
getSourcePosition: (d) => d.from,
|
||||
getTargetPosition: (d) => d.to,
|
||||
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
||||
getWidth: () => 1.3,
|
||||
getWidth: () => 2.2,
|
||||
widthUnits: 'pixels',
|
||||
onHover: (info) => {
|
||||
if (!info.object) { clearDeckHover(); return; }
|
||||
@ -245,7 +251,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
||||
radiusUnits: 'meters',
|
||||
getRadius: (d) => d.radiusNm * 1852,
|
||||
lineWidthUnits: 'pixels',
|
||||
getLineWidth: () => 1.1,
|
||||
getLineWidth: () => 2.0,
|
||||
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
||||
getPosition: (d) => d.center,
|
||||
onHover: (info) => {
|
||||
@ -403,17 +409,17 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
|
||||
|
||||
/* ─ interactive overlays ─ */
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) ─ */
|
||||
@ -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); } }));
|
||||
}
|
||||
|
||||
/* ─ 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;
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import type { SubcableGeoJson } from '../../entities/subcable/model/types';
|
||||
import type { ActiveTrack } from '../../entities/vesselTrack/model/types';
|
||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||
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';
|
||||
|
||||
export type Map3DSettings = {
|
||||
@ -69,6 +69,8 @@ export interface Map3DProps {
|
||||
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
||||
onCloseTrackMenu?: () => void;
|
||||
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
|
||||
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
|
||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||
}
|
||||
|
||||
export type DashSeg = {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user