develop #28

병합
htlee develop 에서 main 로 2 commits 를 머지했습니다 2026-02-17 11:02:28 +09:00
13개의 변경된 파일793개의 추가작업 그리고 155개의 파일을 삭제

파일 보기

@ -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 = {