From 168bea0621a5ab59576f881ff9a899e3f0f7c977 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Feb 2026 10:52:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(map):=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EA=B0=80=EC=8B=9C=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?+=20=EA=B2=BD=EA=B3=A0=20=EC=84=A0=EB=B0=95=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A — 오버레이 라인 가시성: - Globe/Mercator 쌍끌이·FC·선단 라인 굵기 ~2x 증가 - Globe 범위 원·선단 원 대시 패턴 추가 - Globe 오버레이 호버 시 브리딩(breathing) 맥동 rAF Part B — 경고 선박 강조: - DashboardPage → Map3D alarmMmsiMap 전달 - Globe: 경고 맥동 레이어(ships-globe-alarm-pulse) + 배지(ships-globe-alarm-badge) - Mercator: Deck.gl ScatterplotLayer 맥동 + TextLayer 배지 - 경고 종류별 배지(이/수/환/휴/A), z-index 상향, 호버 스케일 확대 - 경고 필터 OFF 시 맥동/배지 자동 비표시 Part C — Mercator 선명 라벨: - MapLibre 라벨 → Deck.gl TextLayer 교체 (z-order 안정) Part D — 프로젝션 전환 안정화: - Globe→Mercator 전환 시 Globe custom layer 맵에서 분리 - alarm rAF에 projectionBusyRef 가드 추가 - overlay.setProps() stale WebGL 자원 참조 방지 Part E — 김개발(DEV) 모드 더미 데이터: - mockOverlayData.ts: 서해 12척 가상 선박 (5종 경고 시나리오) - 김개발 로그인 시 자동 주입, 일반 계정 미노출 Co-Authored-By: Claude Opus 4.6 --- .../legacyDashboard/dev/mockOverlayData.ts | 216 ++++++++++++++++++ .../features/legacyDashboard/model/types.ts | 18 ++ .../web/src/pages/dashboard/DashboardPage.tsx | 34 ++- apps/web/src/widgets/map3d/Map3D.tsx | 5 +- .../src/widgets/map3d/hooks/useDeckLayers.ts | 86 ++++++- .../map3d/hooks/useGlobeFcFleetOverlay.ts | 72 +++++- .../map3d/hooks/useGlobePairOverlay.ts | 74 +++++- .../widgets/map3d/hooks/useGlobeShipLabels.ts | 128 ++--------- .../widgets/map3d/hooks/useGlobeShipLayers.ts | 173 +++++++++++++- .../src/widgets/map3d/hooks/useGlobeShips.ts | 4 + .../map3d/hooks/useProjectionToggle.ts | 9 + .../widgets/map3d/lib/deckLayerFactories.ts | 125 +++++++++- apps/web/src/widgets/map3d/types.ts | 4 +- 13 files changed, 793 insertions(+), 155 deletions(-) create mode 100644 apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts diff --git a/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts b/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts new file mode 100644 index 0000000..d88922a --- /dev/null +++ b/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts @@ -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 & Pick): 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 & Pick, +): 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)); diff --git a/apps/web/src/features/legacyDashboard/model/types.ts b/apps/web/src/features/legacyDashboard/model/types.ts index c2c4b12..fd19739 100644 --- a/apps/web/src/features/legacyDashboard/model/types.ts +++ b/apps/web/src/features/legacyDashboard/model/types.ts @@ -88,3 +88,21 @@ export const LEGACY_ALARM_KIND_LABEL: Record = { 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 = { + 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] }, +}; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 850406f..1e69fdf 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -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(); + 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} /> (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, }, ); diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index c580f69..3df9e4a 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -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; + alarmMmsiMap?: Map; }, ) { 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([]); + 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({ + 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; diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts index 65c6a7e..c814f69 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts @@ -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, @@ -45,6 +55,7 @@ export function useGlobeFcFleetOverlay( overlays, fcLinks, fleetCircles, projection, mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, } = opts; + const breatheRafRef = useRef(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]); } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts index 41176a2..c8ec41b 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts @@ -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, @@ -30,6 +41,7 @@ export function useGlobePairOverlay( }, ) { const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts; + const breatheRafRef = useRef(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]); } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts index 7fbd70a..9c45fcf 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLabels.ts @@ -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, - projectionBusyRef: MutableRefObject, + _projectionBusyRef: MutableRefObject, opts: { projection: MapProjectionId; settings: Map3DSettings; @@ -20,12 +23,10 @@ export function useGlobeShipLabels( legacyHits: Map | null | undefined; selectedMmsi: number | null; mapSyncEpoch: number; + alarmMmsiMap?: Map; }, ) { - 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[] = []; - 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 = { 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]); } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index ece38f3..d5b82c4 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -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, projectionBusyRef: MutableRefObject, @@ -41,14 +49,17 @@ export function useGlobeShipLayers( isBaseHighlightedMmsi: (mmsi: number) => boolean; mapSyncEpoch: number; onGlobeShipsReady?: (ready: boolean) => void; + alarmMmsiMap?: Map; }, ) { const { projection, settings, shipData, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, mapSyncEpoch, onGlobeShipsReady, + alarmMmsiMap, } = opts; const epochRef = useRef(-1); + const breatheRafRef = useRef(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 => { + 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]); } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts index d0105c4..7868b31 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -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; }, ) { // 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 호버 오버레이 + 클릭 선택 diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts index c38cc38..14c03a8 100644 --- a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -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 { diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts index 8963e2b..52a2d72 100644 --- a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -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; + alarmTargets?: AisTarget[]; + alarmMmsiMap?: Map; + 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ + 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({ + 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({ + 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; } diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 6a1d61d..afb24d1 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -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; } export type DashSeg = {