From 3497b8c7e2ae424cc2a22f4d42131378a5e51773 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 18:42:49 +0900 Subject: [PATCH] feat(dashboard): alarms filter + legend/palette sync + map polish --- apps/web/index.html | 4 +- apps/web/public/favicon.svg | 21 + apps/web/src/app/styles.css | 69 + .../features/legacyDashboard/model/derive.ts | 33 +- .../features/legacyDashboard/model/types.ts | 16 + .../web/src/pages/dashboard/DashboardPage.tsx | 97 +- apps/web/src/shared/lib/map/palette.ts | 58 + apps/web/src/widgets/alarms/AlarmsPanel.tsx | 8 +- apps/web/src/widgets/info/VesselInfoPanel.tsx | 3 +- apps/web/src/widgets/legend/MapLegend.tsx | 45 +- apps/web/src/widgets/map3d/Map3D.tsx | 1410 +++++++++++------ .../src/widgets/relations/RelationsPanel.tsx | 7 +- 12 files changed, 1255 insertions(+), 516 deletions(-) create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/shared/lib/map/palette.ts diff --git a/apps/web/index.html b/apps/web/index.html index 7277cb1..8dd863c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,9 +2,9 @@ - + - 906척 실시간 조업 감시 — 선단 연관관계 + WING 조업감시 데모
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..8490944 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index b5b2d94..36528cf 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -465,6 +465,75 @@ body { white-space: nowrap; } +/* Alarm filter (dropdown) */ +.alarm-filter { + position: relative; +} + +.alarm-filter__summary { + list-style: none; + cursor: pointer; + padding: 2px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(30, 41, 59, 0.55); + color: var(--text); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.4px; + user-select: none; + white-space: nowrap; +} + +.alarm-filter__summary::-webkit-details-marker { + display: none; +} + +.alarm-filter__menu { + position: absolute; + right: 0; + top: 22px; + z-index: 2000; + min-width: 170px; + padding: 6px; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(15, 23, 42, 0.98); + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55); +} + +.alarm-filter__row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 6px; + cursor: pointer; + font-size: 10px; + color: var(--text); + user-select: none; +} + +.alarm-filter__row:hover { + background: rgba(59, 130, 246, 0.08); +} + +.alarm-filter__row input { + cursor: pointer; +} + +.alarm-filter__cnt { + margin-left: auto; + font-size: 9px; + color: var(--muted); +} + +.alarm-filter__sep { + height: 1px; + background: rgba(30, 58, 95, 0.85); + margin: 4px 0; +} + /* Relation panel */ .rel-panel { background: var(--card); diff --git a/apps/web/src/features/legacyDashboard/model/derive.ts b/apps/web/src/features/legacyDashboard/model/derive.ts index cf2d569..01f839a 100644 --- a/apps/web/src/features/legacyDashboard/model/derive.ts +++ b/apps/web/src/features/legacyDashboard/model/derive.ts @@ -301,14 +301,33 @@ export function computeLegacyAlarms(args: { }); } - // Most recent first by timeLabel (approx), then by severity. - const sevScore = (s: "cr" | "hi") => (s === "cr" ? 2 : 1); + // Fixed category priority (independent of severity): + // 1) 수역 이탈 2) 쌍 이격 경고 3) 환적 의심 4) 휴어기 조업 의심 5) AIS 지연 + // Within each category: most recent first (smaller N in "-N분" is more recent). + const kindPriority: Record = { + zone_violation: 0, + pair_separation: 1, + transshipment: 2, + closed_season: 3, + ais_stale: 4, + }; + + const parseAgeMin = (label: string) => { + if (label === "방금") return 0; + const m = /-(\\d+)분/.exec(label); + if (m) return Number(m[1]); + return Number.POSITIVE_INFINITY; + }; + alarms.sort((a, b) => { - const av = sevScore(b.severity) - sevScore(a.severity); - if (av !== 0) return av; - const at = Number(a.timeLabel.replace(/[^0-9]/g, "")) || 0; - const bt = Number(b.timeLabel.replace(/[^0-9]/g, "")) || 0; - return at - bt; + const ak = kindPriority[a.kind] ?? 999; + const bk = kindPriority[b.kind] ?? 999; + if (ak !== bk) return ak - bk; + const am = parseAgeMin(a.timeLabel); + const bm = parseAgeMin(b.timeLabel); + if (am !== bm) return am - bm; + // Stable tie-break. + return a.text.localeCompare(b.text); }); return alarms; diff --git a/apps/web/src/features/legacyDashboard/model/types.ts b/apps/web/src/features/legacyDashboard/model/types.ts index 3ff2bf5..c2c4b12 100644 --- a/apps/web/src/features/legacyDashboard/model/types.ts +++ b/apps/web/src/features/legacyDashboard/model/types.ts @@ -72,3 +72,19 @@ export type LegacyAlarm = { text: string; relatedMmsi: number[]; }; + +export const LEGACY_ALARM_KINDS: LegacyAlarmKind[] = [ + "pair_separation", + "transshipment", + "closed_season", + "ais_stale", + "zone_violation", +]; + +export const LEGACY_ALARM_KIND_LABEL: Record = { + pair_separation: "쌍 이격 경고", + transshipment: "환적 의심", + closed_season: "휴어기 조업 의심", + ais_stale: "AIS 지연", + zone_violation: "수역 이탈", +}; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index edd7764..50dea15 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -4,7 +4,8 @@ import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettings import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import { MapToggles } from "../../features/mapToggles/MapToggles"; import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid"; -import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; +import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types"; +import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types"; import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels"; import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; @@ -117,6 +118,10 @@ export function DashboardPage() { }); const [fleetRelationSortMode, setFleetRelationSortMode] = useState("count"); + const [alarmKindEnabled, setAlarmKindEnabled] = useState>(() => { + return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record; + }); + const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); const [settings, setSettings] = useState({ @@ -183,6 +188,26 @@ export function DashboardPage() { const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]); const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]); + const alarmKindCounts = useMemo(() => { + const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record; + for (const a of alarms) { + base[a.kind] = (base[a.kind] ?? 0) + 1; + } + return base; + }, [alarms]); + + const enabledAlarmKinds = useMemo(() => { + return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]); + }, [alarmKindEnabled]); + + const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length; + + const filteredAlarms = useMemo(() => { + if (allAlarmKindsEnabled) return alarms; + const enabled = new Set(enabledAlarmKinds); + return alarms.filter((a) => enabled.has(a.kind)); + }, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]); + const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]); const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]); const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]); @@ -253,6 +278,8 @@ export function DashboardPage() { const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]); const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode; + const enabledAlarmKindCount = enabledAlarmKinds.length; + const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`; return (
@@ -339,7 +366,7 @@ export function DashboardPage() { 지구본
-
지도 우하단 Attribution(라이센스) 표기 유지
+ {/* Attribution (license) stays visible in the map UI; no need to repeat it here. */}
@@ -421,9 +448,69 @@ export function DashboardPage() { />
-
-
실시간 경고
- +
+
+
+ 실시간 경고{" "} + + ({filteredAlarms.length}/{alarms.length}) + +
+ + {LEGACY_ALARM_KINDS.length <= 3 ? ( +
+ {LEGACY_ALARM_KINDS.map((k) => ( + + ))} +
+ ) : ( +
+ + {alarmFilterSummary} + +
+ +
+ {LEGACY_ALARM_KINDS.map((k) => ( + + ))} +
+
+ )} +
+ +
+ +
{adminMode ? ( diff --git a/apps/web/src/shared/lib/map/palette.ts b/apps/web/src/shared/lib/map/palette.ts new file mode 100644 index 0000000..9dc1c56 --- /dev/null +++ b/apps/web/src/shared/lib/map/palette.ts @@ -0,0 +1,58 @@ +export type Rgb = [number, number, number]; +export type DeckRgba = [number, number, number, number]; + +export function rgbToHex(rgb: Rgb): string { + const toHex = (v: number) => { + const clamped = Math.max(0, Math.min(255, Math.round(v))); + return clamped.toString(16).padStart(2, "0"); + }; + return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`; +} + +export function rgba(rgb: Rgb, alpha01: number): string { + const a = Math.max(0, Math.min(1, alpha01)); + return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`; +} + +export function deckRgba(rgb: Rgb, alpha255: number): DeckRgba { + const a = Math.max(0, Math.min(255, Math.round(alpha255))); + return [rgb[0], rgb[1], rgb[2], a]; +} + +// CN-permit (legacy) ship category colors. Used by both map layers and legend. +export const LEGACY_CODE_COLORS_RGB: Record = { + PT: [30, 64, 175], // #1e40af + "PT-S": [234, 88, 12], // #ea580c + GN: [16, 185, 129], // #10b981 + OT: [139, 92, 246], // #8b5cf6 + PS: [239, 68, 68], // #ef4444 + FC: [245, 158, 11], // #f59e0b + C21: [236, 72, 153], // #ec4899 +}; + +export const LEGACY_CODE_COLORS_HEX: Record = Object.fromEntries( + Object.entries(LEGACY_CODE_COLORS_RGB).map(([k, rgb]) => [k, rgbToHex(rgb)]), +) as Record; + +// Non-target AIS ships should be visible but muted (speed encoded mainly via brightness). +export const OTHER_AIS_SPEED_RGB = { + fast: [148, 163, 184] as Rgb, // SOG >= 10 + moving: [100, 116, 139] as Rgb, // 1 <= SOG < 10 + stopped: [71, 85, 105] as Rgb, // SOG < 1 +}; + +export const OTHER_AIS_SPEED_HEX = { + fast: rgbToHex(OTHER_AIS_SPEED_RGB.fast), + moving: rgbToHex(OTHER_AIS_SPEED_RGB.moving), + stopped: rgbToHex(OTHER_AIS_SPEED_RGB.stopped), +}; + +// Overlay palette: keep a cohesive "warm alert" family, but ensure each overlay type is distinguishable. +export const OVERLAY_RGB = { + pairNormal: [59, 130, 246] as Rgb, // blue-500 + pairWarn: [251, 113, 133] as Rgb, // rose-400 (쌍 이격 경고) + fcTransfer: [249, 115, 22] as Rgb, // orange-500 (환적 연결) + fleetRange: [250, 204, 21] as Rgb, // yellow-400 (선단 범위) + suspicious: [239, 68, 68] as Rgb, // red-500 +}; + diff --git a/apps/web/src/widgets/alarms/AlarmsPanel.tsx b/apps/web/src/widgets/alarms/AlarmsPanel.tsx index 215676b..35f79d8 100644 --- a/apps/web/src/widgets/alarms/AlarmsPanel.tsx +++ b/apps/web/src/widgets/alarms/AlarmsPanel.tsx @@ -1,13 +1,15 @@ import type { LegacyAlarm } from "../../features/legacyDashboard/model/types"; export function AlarmsPanel({ alarms, onSelectMmsi }: { alarms: LegacyAlarm[]; onSelectMmsi?: (mmsi: number) => void }) { - const shown = alarms.slice(0, 6); + if (alarms.length === 0) { + return
(현재 경고 없음)
; + } return (
- {shown.map((a, idx) => ( + {alarms.map((a, idx) => (
{ if (!onSelectMmsi) return; diff --git a/apps/web/src/widgets/info/VesselInfoPanel.tsx b/apps/web/src/widgets/info/VesselInfoPanel.tsx index b0c4a1f..845640e 100644 --- a/apps/web/src/widgets/info/VesselInfoPanel.tsx +++ b/apps/web/src/widgets/info/VesselInfoPanel.tsx @@ -2,6 +2,7 @@ import { ZONE_META } from "../../entities/zone/model/meta"; import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; +import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette"; type Props = { vessel: DerivedLegacyVessel; @@ -93,7 +94,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
쌍 이격 - 3 ? "#F59E0B" : "#22C55E" }}> + 3 ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E" }}> {pairDist.toFixed(2)}NM {pairDist > 3 ? "⚠" : "✓"}
diff --git a/apps/web/src/widgets/legend/MapLegend.tsx b/apps/web/src/widgets/legend/MapLegend.tsx index 5682992..3377175 100644 --- a/apps/web/src/widgets/legend/MapLegend.tsx +++ b/apps/web/src/widgets/legend/MapLegend.tsx @@ -1,4 +1,5 @@ import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta"; +import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; export function MapLegend() { return ( @@ -12,48 +13,56 @@ export function MapLegend() { ))}
- AIS 선박(속도) + 기타 AIS 선박(속도)
-
+
SOG ≥ 10 kt
-
+
1 ≤ SOG < 10 kt
-
- SOG < 1 kt (or unknown) +
+ SOG < 1 kt +
+
+
+ SOG unknown
CN Permit(업종)
-
+
PT 본선 (ring + 색상)
-
+
PT-S 부속선
-
+
GN 유망
-
+
OT 1척식
-
+
PS 위망
-
+
FC 운반선
+
+
+ C21 +
밀도(3D) @@ -66,25 +75,29 @@ export function MapLegend() { 연결선
-
+
PT↔PT-S 쌍 (정상)
-
+
쌍 연결범위
-
+
쌍 이격 경고 (>3NM)
-
+
FC 환적 연결 (dashed)
-
+
선단 범위
+
+
+ FC 환적 연결 (의심) +
); } diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 2468837..d3da34b 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -17,6 +17,7 @@ import type { ZoneId } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; +import { LEGACY_CODE_COLORS_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; export type Map3DSettings = { @@ -183,7 +184,7 @@ function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { return false; } const clauses = hoveredFleetMmsiList.map((mmsi) => - ["in", mmsi, ["coalesce", ["get", "vesselMmsis"], []]] as unknown[], + ["in", mmsi, ["coalesce", ["get", "vesselMmsis"], ["literal", []]]] as unknown[], ); return ["any", ...clauses] as unknown[]; } @@ -545,21 +546,141 @@ function getGlobeBaseShipColor({ if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); } - if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.75)"; - if (sog >= 10) return "#3b82f6"; - if (sog >= 1) return "#22c55e"; - return "rgba(100,116,139,0.75)"; + // Non-target AIS should be visible but muted so target vessels stand out. + // Encode speed mostly via brightness (not hue) to avoid clashing with target category colors. + if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.55)"; + if (sog >= 10) return "rgba(148,163,184,0.78)"; + if (sog >= 1) return "rgba(100,116,139,0.74)"; + return "rgba(71,85,105,0.68)"; } -const LEGACY_CODE_COLORS: Record = { - PT: [30, 64, 175], // #1e40af - "PT-S": [234, 88, 12], // #ea580c - GN: [16, 185, 129], // #10b981 - OT: [139, 92, 246], // #8b5cf6 - PS: [239, 68, 68], // #ef4444 - FC: [245, 158, 11], // #f59e0b - C21: [236, 72, 153], // #ec4899 -}; +const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB; + +const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal; +const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn; +const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer; +const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; +const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; + +// Deck.gl color constants (avoid per-object allocations inside accessors). +const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], + OVERLAY_PAIR_NORMAL_RGB[1], + OVERLAY_PAIR_NORMAL_RGB[2], + 110, +]; +const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], + OVERLAY_PAIR_WARN_RGB[1], + OVERLAY_PAIR_WARN_RGB[2], + 170, +]; +const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], + OVERLAY_PAIR_NORMAL_RGB[1], + OVERLAY_PAIR_NORMAL_RGB[2], + 85, +]; +const PAIR_LINE_WARN_DECK: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], + OVERLAY_PAIR_WARN_RGB[1], + OVERLAY_PAIR_WARN_RGB[2], + 220, +]; +const FC_LINE_NORMAL_DECK: [number, number, number, number] = [ + OVERLAY_FC_TRANSFER_RGB[0], + OVERLAY_FC_TRANSFER_RGB[1], + OVERLAY_FC_TRANSFER_RGB[2], + 200, +]; +const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [ + OVERLAY_SUSPICIOUS_RGB[0], + OVERLAY_SUSPICIOUS_RGB[1], + OVERLAY_SUSPICIOUS_RGB[2], + 220, +]; +const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], + OVERLAY_FLEET_RANGE_RGB[1], + OVERLAY_FLEET_RANGE_RGB[2], + 140, +]; +const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], + OVERLAY_FLEET_RANGE_RGB[1], + OVERLAY_FLEET_RANGE_RGB[2], + 6, +]; + +const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], + OVERLAY_PAIR_NORMAL_RGB[1], + OVERLAY_PAIR_NORMAL_RGB[2], + 200, +]; +const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], + OVERLAY_PAIR_WARN_RGB[1], + OVERLAY_PAIR_WARN_RGB[2], + 240, +]; +const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_NORMAL_RGB[0], + OVERLAY_PAIR_NORMAL_RGB[1], + OVERLAY_PAIR_NORMAL_RGB[2], + 245, +]; +const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [ + OVERLAY_PAIR_WARN_RGB[0], + OVERLAY_PAIR_WARN_RGB[1], + OVERLAY_PAIR_WARN_RGB[2], + 245, +]; +const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ + OVERLAY_FC_TRANSFER_RGB[0], + OVERLAY_FC_TRANSFER_RGB[1], + OVERLAY_FC_TRANSFER_RGB[2], + 235, +]; +const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [ + OVERLAY_SUSPICIOUS_RGB[0], + OVERLAY_SUSPICIOUS_RGB[1], + OVERLAY_SUSPICIOUS_RGB[2], + 245, +]; +const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], + OVERLAY_FLEET_RANGE_RGB[1], + OVERLAY_FLEET_RANGE_RGB[2], + 220, +]; +const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [ + OVERLAY_FLEET_RANGE_RGB[0], + OVERLAY_FLEET_RANGE_RGB[1], + OVERLAY_FLEET_RANGE_RGB[2], + 42, +]; + +// MapLibre overlay colors. +const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55); +const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95); +const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95); +const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98); + +const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45); +const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75); +const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92); +const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92); + +const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92); +const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95); +const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98); +const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98); + +const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02); +const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16); +const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); +const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); const DEPTH_DISABLED_PARAMS = { // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. @@ -935,10 +1056,11 @@ function getShipColor( if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; return [245, 158, 11, 235]; } - if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 160]; - if (t.sog >= 10) return [59, 130, 246, 220]; - if (t.sog >= 1) return [34, 197, 94, 210]; - return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 160]; + // Non-target AIS: muted gray scale (avoid clashing with target category colors like PT/GN/etc). + if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 130]; + if (t.sog >= 10) return [148, 163, 184, 185]; // slate-400 + if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; // slate-500 + return [71, 85, 105, 165]; // slate-600 } type DashSeg = { @@ -1139,6 +1261,9 @@ export function Map3D({ "ships-globe-halo", "ships-globe-outline", "ships-globe", + "ships-globe-hover-halo", + "ships-globe-hover-outline", + "ships-globe-hover", "pair-lines-ml", "fc-lines-ml", "pair-range-ml", @@ -1183,6 +1308,10 @@ export function Map3D({ effectiveHoveredPairMmsiSet, ], ); + const highlightedMmsiSetForShips = useMemo( + () => (projection === "globe" ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined), + [projection, hoveredMmsiSetRef, externalHighlightedSetRef, highlightedMmsiSetCombined], + ); const hoveredShipSignature = useMemo( () => `${makeSetSignature(hoveredMmsiSetRef)}|${makeSetSignature(externalHighlightedSetRef)}|${makeSetSignature( @@ -1243,14 +1372,43 @@ export function Map3D({ return byMmsi; }, [shipData]); - const hasAuxiliarySelectModifier = (ev?: { - shiftKey?: boolean; - ctrlKey?: boolean; - metaKey?: boolean; - } | null): boolean => { - if (!ev) return false; - return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); - }; + const shipLayerData = useMemo(() => { + if (shipData.length === 0) return shipData; + return [...shipData]; + }, [shipData]); + + const shipHighlightSet = useMemo(() => { + const out = new Set(highlightedMmsiSetForShips); + if (selectedMmsi) out.add(selectedMmsi); + return out; + }, [highlightedMmsiSetForShips, selectedMmsi]); + + const shipHoverOverlaySet = useMemo( + () => + projection === "globe" + ? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet) + : shipHighlightSet, + [projection, highlightedMmsiSetCombined, shipHighlightSet], + ); + + const shipOverlayLayerData = useMemo(() => { + if (shipLayerData.length === 0) return []; + if (shipHighlightSet.size === 0) return []; + + return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); + }, [shipHighlightSet, shipLayerData]); + + const hasAuxiliarySelectModifier = useCallback( + (ev?: { + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + } | null): boolean => { + if (!ev) return false; + return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); + }, + [], + ); const toFleetMmsiList = useCallback((value: unknown) => { if (!Array.isArray(value)) return []; @@ -1300,6 +1458,7 @@ export function Map3D({ ownerKey: string | null; vesselMmsis: number[]; }>({ ownerKey: null, vesselMmsis: [] }); + const globeHoverShipSignatureRef = useRef(""); const clearMapFleetHoverState = useCallback(() => { mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; @@ -1403,6 +1562,14 @@ export function Map3D({ if (onProjectionLoadingChange) { onProjectionLoadingChange(false); } + // Many layer "ensure" functions bail out while projectionBusyRef is true. + // Trigger a sync pulse when loading ends so globe/mercator layers appear immediately + // without requiring a user toggle (e.g., industry filter). + setMapSyncEpoch((prev) => prev + 1); + requestAnimationFrame(() => { + kickRepaint(mapRef.current); + setMapSyncEpoch((prev) => prev + 1); + }); }, [clearProjectionBusyTimer, onProjectionLoadingChange]); const setProjectionLoading = useCallback( @@ -1477,30 +1644,19 @@ export function Map3D({ } }, []); - const ensureMercatorOverlays = useCallback(() => { + const ensureMercatorOverlay = useCallback(() => { const map = mapRef.current; if (!map) return null; - - const ensureLayer = (ref: { current: MapboxOverlay | null }) => { - if (ref.current) return ref.current; - try { - const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); - map.addControl(next); - ref.current = next; - return next; - } catch (e) { - console.warn("Deck overlay create failed:", e); - return null; - } - }; - - const base = ensureLayer(overlayRef); - if (!base) return null; - - const interaction = ensureLayer(overlayInteractionRef); - if (!interaction) return null; - - return { base, interaction }; + if (overlayRef.current) return overlayRef.current; + try { + const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(next); + overlayRef.current = next; + return next; + } catch (e) { + console.warn("Deck overlay create failed:", e); + return null; + } }, []); const clearGlobeNativeLayers = useCallback(() => { @@ -1511,6 +1667,9 @@ export function Map3D({ "ships-globe-halo", "ships-globe-outline", "ships-globe", + "ships-globe-hover-halo", + "ships-globe-hover-outline", + "ships-globe-hover", "pair-lines-ml", "fc-lines-ml", "fleet-circles-ml-fill", @@ -1525,6 +1684,7 @@ export function Map3D({ const sourceIds = [ "ships-globe-src", + "ships-globe-hover-src", "pair-lines-ml-src", "fc-lines-ml-src", "fleet-circles-ml-src", @@ -1579,9 +1739,9 @@ export function Map3D({ // - mercator: MapboxOverlay interleaved (fast, feature-rich) // - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned) if (projectionRef.current === "mercator") { - const overlays = ensureMercatorOverlays(); - if (!overlays) return; - overlayRef.current = overlays.base; + const overlay = ensureMercatorOverlay(); + if (!overlay) return; + overlayRef.current = overlay; } else { globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ id: "deck-globe", @@ -1848,7 +2008,7 @@ export function Map3D({ // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. disposeGlobeDeckLayer(); - ensureMercatorOverlays(); + ensureMercatorOverlay(); } // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. @@ -1884,7 +2044,7 @@ export function Map3D({ if (settleCleanup) settleCleanup(); if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlays, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); // Base map toggle useEffect(() => { @@ -2240,6 +2400,7 @@ export function Map3D({ } catch { // ignore } + globeHoverShipSignatureRef.current = ""; reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2404,12 +2565,16 @@ export function Map3D({ visibility, "circle-sort-key": [ "case", - ["==", ["get", "selected"], 1], - 90, - ["==", ["get", "highlighted"], 1], - 80, + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 120, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 115, ["==", ["get", "permitted"], 1], + 110, + ["==", ["get", "selected"], 1], 60, + ["==", ["get", "highlighted"], 1], + 55, 20, ] as never, }, @@ -2434,6 +2599,24 @@ export function Map3D({ } else { try { map.setLayoutProperty(haloId, "visibility", visibility); + map.setLayoutProperty( + haloId, + "circle-sort-key", + [ + "case", + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 120, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 115, + ["==", ["get", "permitted"], 1], + 110, + ["==", ["get", "selected"], 1], + 60, + ["==", ["get", "highlighted"], 1], + 55, + 20, + ] as never, + ); map.setPaintProperty( haloId, "circle-color", @@ -2443,9 +2626,7 @@ export function Map3D({ "rgba(14,234,255,1)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,1)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,0.95)", - "rgba(59,130,246,1)", + ["coalesce", ["get", "shipColor"], "#64748b"], ] as never, ); map.setPaintProperty(haloId, "circle-opacity", [ @@ -2478,9 +2659,7 @@ export function Map3D({ "rgba(14,234,255,0.95)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,0.95)", - "rgba(59,130,246,0.75)", + ["coalesce", ["get", "shipColor"], "#64748b"], ] as never, "circle-stroke-width": [ "case", @@ -2498,12 +2677,16 @@ export function Map3D({ visibility, "circle-sort-key": [ "case", - ["==", ["get", "selected"], 1], - 100, - ["==", ["get", "highlighted"], 1], - 90, + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 130, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 125, ["==", ["get", "permitted"], 1], + 120, + ["==", ["get", "selected"], 1], 70, + ["==", ["get", "highlighted"], 1], + 65, 30, ] as never, }, @@ -2516,6 +2699,24 @@ export function Map3D({ } else { try { map.setLayoutProperty(outlineId, "visibility", visibility); + map.setLayoutProperty( + outlineId, + "circle-sort-key", + [ + "case", + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 130, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 125, + ["==", ["get", "permitted"], 1], + 120, + ["==", ["get", "selected"], 1], + 70, + ["==", ["get", "highlighted"], 1], + 65, + 30, + ] as never, + ); map.setPaintProperty( outlineId, "circle-stroke-color", @@ -2525,9 +2726,7 @@ export function Map3D({ "rgba(14,234,255,0.95)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", - ["==", ["get", "permitted"], 1], - "rgba(125,211,252,0.95)", - "rgba(59,130,246,0.75)", + ["coalesce", ["get", "shipColor"], "#64748b"], ] as never, ); map.setPaintProperty( @@ -2560,12 +2759,16 @@ export function Map3D({ visibility, "symbol-sort-key": [ "case", - ["==", ["get", "selected"], 1], - 95, - ["==", ["get", "highlighted"], 1], - 85, + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 140, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 135, ["==", ["get", "permitted"], 1], - 65, + 130, + ["==", ["get", "selected"], 1], + 80, + ["==", ["get", "highlighted"], 1], + 75, 45, ] as never, "icon-image": imgId, @@ -2590,6 +2793,323 @@ export function Map3D({ "icon-rotation-alignment": "map", "icon-pitch-alignment": "map", }, + paint: { + "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, + "icon-opacity": [ + "case", + ["==", ["get", "permitted"], 1], + 1, + ["==", ["get", "selected"], 1], + 0.86, + ["==", ["get", "highlighted"], 1], + 0.82, + 0.66, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship symbol layer add failed:", e); + } + } else { + try { + map.setLayoutProperty(symbolId, "visibility", visibility); + map.setLayoutProperty( + symbolId, + "symbol-sort-key", + [ + "case", + ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], + 140, + ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], + 135, + ["==", ["get", "permitted"], 1], + 130, + ["==", ["get", "selected"], 1], + 80, + ["==", ["get", "highlighted"], 1], + 75, + 45, + ] as never, + ); + map.setPaintProperty( + symbolId, + "icon-opacity", + [ + "case", + ["==", ["get", "permitted"], 1], + 1, + ["==", ["get", "selected"], 1], + 0.86, + ["==", ["get", "highlighted"], 1], + 0.82, + 0.66, + ] as never, + ); + } catch { + // ignore + } + } + + // Selection and highlight are now source-data driven. + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + shipData, + legacyHits, + selectedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = "ship-globe-icon"; + const srcId = "ships-globe-hover-src"; + const haloId = "ships-globe-hover-halo"; + const outlineId = "ships-globe-hover-outline"; + const symbolId = "ships-globe-hover"; + + const remove = () => { + for (const id of [symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + globeHoverShipSignatureRef.current = ""; + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== "globe" || !settings.showShips || shipHoverOverlaySet.size === 0) { + remove(); + return; + } + + if (globeShipsEpochRef.current !== mapSyncEpoch) { + remove(); + globeShipsEpochRef.current = mapSyncEpoch; + } + + ensureFallbackShipImage(map, imgId); + if (!map.hasImage(imgId)) { + return; + } + + const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); + if (hovered.length === 0) { + remove(); + return; + } + const hoverSignature = hovered + .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) + .join("|"); + const hasHoverSource = map.getSource(srcId) != null; + const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); + if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) { + return; + } + globeHoverShipSignatureRef.current = hoverSignature; + const needReorder = !hasHoverSource || !hasHoverLayers; + + const hoverGeojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: hovered.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const hull = clampNumber( + (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, + 50, + 420, + ); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const scale = selected ? 1.16 : 1.1; + return { + type: "Feature", + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: "Point", coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || "", + cog: heading, + heading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), + iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), + iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), + iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), + selected: selected ? 1 : 0, + permitted: !!legacy, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(hoverGeojson); + else map.addSource(srcId, { type: "geojson", data: hoverGeojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Ship hover source setup failed:", e); + return; + } + + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: "circle", + source: srcId, + layout: { + visibility: "visible", + "circle-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 120, + ["==", ["get", "permitted"], 1], + 115, + 110, + ] as never, + }, + paint: { + "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + "circle-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,1)", + "rgba(245,158,11,1)", + ] as never, + "circle-opacity": 0.42, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship hover halo layer add failed:", e); + } + } else { + map.setLayoutProperty(haloId, "visibility", "visible"); + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: "circle", + source: srcId, + paint: { + "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + "circle-color": "rgba(0,0,0,0)", + "circle-stroke-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.95)", + "rgba(245,158,11,0.95)", + ] as never, + "circle-stroke-width": [ + "case", + ["==", ["get", "selected"], 1], + 3.8, + 2.2, + ] as never, + "circle-stroke-opacity": 0.9, + }, + layout: { + visibility: "visible", + "circle-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 121, + ["==", ["get", "permitted"], 1], + 116, + 111, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn("Ship hover outline layer add failed:", e); + } + } else { + map.setLayoutProperty(outlineId, "visibility", "visible"); + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: "symbol", + source: srcId, + layout: { + visibility: "visible", + "symbol-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 122, + ["==", ["get", "permitted"], 1], + 117, + 112, + ] as never, + "icon-image": imgId, + "icon-size": [ + "interpolate", + ["linear"], + ["zoom"], + 3, + ["to-number", ["get", "iconSize3"], 0.35], + 7, + ["to-number", ["get", "iconSize7"], 0.45], + 10, + ["to-number", ["get", "iconSize10"], 0.56], + 14, + ["to-number", ["get", "iconSize14"], 0.72], + ] as unknown as number[], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-anchor": "center", + "icon-rotate": ["to-number", ["get", "heading"], 0], + // Keep the icon on the sea surface. + "icon-rotation-alignment": "map", + "icon-pitch-alignment": "map", + }, paint: { "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "icon-opacity": 1, @@ -2598,18 +3118,15 @@ export function Map3D({ before, ); } catch (e) { - console.warn("Ship symbol layer add failed:", e); + console.warn("Ship hover symbol layer add failed:", e); } } else { - try { - map.setLayoutProperty(symbolId, "visibility", visibility); - } catch { - // ignore - } + map.setLayoutProperty(symbolId, "visibility", "visible"); } - // Selection and highlight are now source-data driven. - reorderGlobeFeatureLayers(); + if (needReorder) { + reorderGlobeFeatureLayers(); + } kickRepaint(map); }; @@ -2620,10 +3137,10 @@ export function Map3D({ }, [ projection, settings.showShips, - shipData, + shipLayerData, legacyHits, + shipHoverOverlaySet, selectedMmsi, - isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, ]); @@ -2768,10 +3285,10 @@ export function Map3D({ "line-color": [ "case", ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.98)", + ["case", ["boolean", ["get", "warn"], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ["boolean", ["get", "warn"], false], - "rgba(245,158,11,0.95)", - "rgba(59,130,246,0.55)", + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML, ] as never, "line-width": [ "case", @@ -2885,10 +3402,10 @@ export function Map3D({ "line-color": [ "case", ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.98)", + ["case", ["boolean", ["get", "suspicious"], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ["boolean", ["get", "suspicious"], false], - "rgba(239,68,68,0.95)", - "rgba(217,119,6,0.92)", + FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML, ] as never, "line-width": ["case", ["==", ["get", "highlighted"], 1], 2.0, 1.3] as never, "line-opacity": 0.9, @@ -3032,8 +3549,8 @@ export function Map3D({ "fill-color": [ "case", ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.16)", - "rgba(245,158,11,0.02)", + FLEET_FILL_ML_HL, + FLEET_FILL_ML, ] as never, "fill-opacity": ["case", ["==", ["get", "highlighted"], 1], 0.7, 0.36] as never, }, @@ -3060,7 +3577,7 @@ export function Map3D({ source: srcId, layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, paint: { - "line-color": ["case", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never, + "line-color": ["case", ["==", ["get", "highlighted"], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, "line-width": ["case", ["==", ["get", "highlighted"], 1], 2, 1.1] as never, "line-opacity": 0.85, }, @@ -3181,10 +3698,10 @@ export function Map3D({ "line-color": [ "case", ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.92)", + ["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ["boolean", ["get", "warn"], false], - "rgba(245,158,11,0.75)", - "rgba(59,130,246,0.45)", + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML, ] as never, "line-width": ["case", ["==", ["get", "highlighted"], 1], 1.6, 1.0] as never, "line-opacity": 0.85, @@ -3249,7 +3766,14 @@ export function Map3D({ map.setPaintProperty( "pair-lines-ml", "line-color", - ["case", pairHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "warn"], false], "rgba(59,130,246,0.55)", "rgba(59,130,246,0.55)"] as never, + [ + "case", + pairHighlightExpr, + ["case", ["boolean", ["get", "warn"], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], + ["boolean", ["get", "warn"], false], + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML, + ] as never, ); map.setPaintProperty( "pair-lines-ml", @@ -3266,7 +3790,14 @@ export function Map3D({ map.setPaintProperty( "fc-lines-ml", "line-color", - ["case", fcEndpointHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "suspicious"], false], "rgba(239,68,68,0.95)", "rgba(217,119,6,0.92)"] as never, + [ + "case", + fcEndpointHighlightExpr, + ["case", ["boolean", ["get", "suspicious"], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], + ["boolean", ["get", "suspicious"], false], + FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML, + ] as never, ); map.setPaintProperty( "fc-lines-ml", @@ -3286,10 +3817,10 @@ export function Map3D({ [ "case", pairHighlightExpr, - "rgba(245,158,11,0.92)", + ["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ["boolean", ["get", "warn"], false], - "rgba(245,158,11,0.75)", - "rgba(59,130,246,0.45)", + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML, ] as never, ); map.setPaintProperty( @@ -3310,8 +3841,8 @@ export function Map3D({ [ "case", fleetHighlightExpr, - "rgba(245,158,11,0.24)", - "rgba(245,158,11,0.02)", + FLEET_FILL_ML_HL, + FLEET_FILL_ML, ] as never, ); map.setPaintProperty( @@ -3324,7 +3855,7 @@ export function Map3D({ map.setPaintProperty( "fleet-circles-ml", "line-color", - ["case", fleetHighlightExpr, "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never, + ["case", fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, ); map.setPaintProperty( "fleet-circles-ml", @@ -3347,22 +3878,6 @@ export function Map3D({ }; }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); - const shipLayerData = useMemo(() => { - if (shipData.length === 0) return shipData; - return [...shipData]; - }, [shipData]); - - const shipHighlightSet = useMemo(() => { - const out = new Set(highlightedMmsiSetCombined); - if (selectedMmsi) out.add(selectedMmsi); - return out; - }, [highlightedMmsiSetCombined, selectedMmsi]); - - const shipOverlayLayerData = useMemo(() => { - if (shipLayerData.length === 0) return shipLayerData; - return shipLayerData; - }, [shipLayerData]); - const clearGlobeTooltip = useCallback(() => { if (!mapTooltipRef.current) return; try { @@ -3716,18 +4231,19 @@ export function Map3D({ const fcLinesInteractive = useMemo(() => { if (!overlays.fcLines || fcDashed.length === 0) return []; - if (shipHighlightSet.size === 0) return []; + if (highlightedMmsiSetCombined.size === 0) return []; return fcDashed.filter( - (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : shipHighlightSet.has(mmsi))), + (line) => + [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), ); - }, [fcDashed, hoveredShipSignature, overlays.fcLines, shipHighlightSet]); + }, [fcDashed, hoveredShipSignature, overlays.fcLines, highlightedMmsiSetCombined]); const fleetCirclesInteractive = useMemo(() => { if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; - if (hoveredFleetOwnerKeys.size === 0 && shipHighlightSet.size === 0) return []; + if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return []; const circles = fleetCircles || []; return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); - }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, shipHighlightSet]); + }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); // Static deck layers for mercator (positions + base states). Interaction overlays are handled separately. useEffect(() => { @@ -3744,8 +4260,7 @@ export function Map3D({ return; } - const refs = ensureMercatorOverlays(); - const deckTarget = refs?.base; + const deckTarget = ensureMercatorOverlay(); if (!deckTarget) return; const layers: unknown[] = []; @@ -3753,6 +4268,19 @@ export function Map3D({ const clearDeckHover = () => { touchDeckHoverState(false); }; + const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false); + const shipOtherData: AisTarget[] = []; + const shipTargetData: AisTarget[] = []; + for (const t of shipLayerData) { + if (isTargetShip(t.mmsi)) shipTargetData.push(t); + else shipOtherData.push(t); + } + const shipOverlayOtherData: AisTarget[] = []; + const shipOverlayTargetData: AisTarget[] = []; + for (const t of shipOverlayLayerData) { + if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t); + else shipOverlayOtherData.push(t); + } if (settings.showDensity) { layers.push( @@ -3785,7 +4313,7 @@ export function Map3D({ radiusMinPixels: 10, lineWidthUnits: "pixels", getLineWidth: () => 1, - getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]), + getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { @@ -3825,7 +4353,7 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), + getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), getWidth: (d) => (d.warn ? 2.2 : 1.4), widthUnits: "pixels", onHover: (info) => { @@ -3863,7 +4391,7 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), + getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), getWidth: () => 1.3, widthUnits: "pixels", onHover: (info) => { @@ -3911,7 +4439,7 @@ export function Map3D({ getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: "pixels", getLineWidth: () => 1.1, - getLineColor: () => [245, 158, 11, 140], + getLineColor: () => FLEET_RANGE_LINE_DECK, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { @@ -3953,43 +4481,273 @@ export function Map3D({ stroked: false, radiusUnits: "meters", getRadius: (d) => d.radiusNm * 1852, - getFillColor: () => [245, 158, 11, 6], + getFillColor: () => FLEET_RANGE_FILL_DECK, getPosition: (d) => d.center, }), ); } - if (settings.showShips && legacyTargetsOrdered.length > 0) { + if (settings.showShips) { + // Always render non-target ships below target ships. + const shipOnHover = (info: PickingInfo) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as AisTarget; + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + }; + const shipOnClick = (info: PickingInfo) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + onDeckSelectOrHighlight( + { + mmsi: (info.object as AisTarget).mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, + true, + ); + }; + + if (shipOtherData.length > 0) { + layers.push( + new IconLayer({ + id: "ships-other", + data: shipOtherData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + 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: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + + // Hover/selection overlay for non-target ships stays below all target ships. + if (shipOverlayOtherData.length > 0) { + layers.push( + new IconLayer({ + id: "ships-overlay-other", + data: shipOverlayOtherData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + 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 (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet), + alphaCutoff: 0.05, + }), + ); + } + + // Target ship halos and icons render above non-target ships. + if (legacyTargetsOrdered.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "legacy-halo", + data: legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: () => FLAT_LEGACY_HALO_RADIUS, + lineWidthUnits: "pixels", + getLineWidth: () => 2, + getLineColor: (d) => { + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (shipTargetData.length > 0) { + layers.push( + new IconLayer({ + id: "ships-target", + data: shipTargetData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + 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: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => getShipColor(d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + } + + // Interaction overlays (hover/selection highlights) are appended so they always render above base layers. + if (overlays.pairRange && pairRangesInteractive.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "pair-range-overlay", + data: 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, + }), + ); + } + + if (overlays.pairLines && pairLinksInteractive.length > 0) { + layers.push( + new LineLayer({ + id: "pair-lines-overlay", + data: 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", + }), + ); + } + + if (overlays.fcLines && fcLinesInteractive.length > 0) { + layers.push( + new LineLayer({ + id: "fc-lines-overlay", + data: 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", + }), + ); + } + + if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "fleet-circles-overlay-fill", + data: 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: 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, + }), + ); + } + + if (settings.showShips && legacyOverlayTargets.length > 0) { layers.push( new ScatterplotLayer({ - id: "legacy-halo", - data: legacyTargetsOrdered, + id: "legacy-halo-overlay", + data: legacyOverlayTargets, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: "pixels", - getRadius: () => FLAT_LEGACY_HALO_RADIUS, + getRadius: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; + return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; + }, lineWidthUnits: "pixels", - getLineWidth: () => 2, + getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), getLineColor: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; const l = legacyHits?.get(d.mmsi); const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; + if (!rgb) return [245, 158, 11, 210]; + return [rgb[0], rgb[1], rgb[2], 210]; }, getPosition: (d) => [d.lon, d.lat] as [number, number], }), ); } - if (settings.showShips) { + if (settings.showShips && shipOverlayTargetData.length > 0) { layers.push( new IconLayer({ - id: "ships", - data: shipLayerData, - pickable: true, + id: "ships-overlay-target", + data: shipOverlayTargetData, + pickable: false, billboard: false, parameters: overlayParams, iconAtlas: "/assets/ship.svg", @@ -4002,36 +4760,15 @@ export function Map3D({ heading: d.heading, }), sizeUnits: "pixels", - getSize: () => FLAT_SHIP_ICON_SIZE, - getColor: (d) => - getShipColor( - d, - null, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - EMPTY_MMSI_SET, - ), - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); + getSize: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; }, - onClick: (info) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - onDeckSelectOrHighlight({ - mmsi: info.object.mmsi, - srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, - }, true); + getColor: (d) => { + if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; + return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); }, - alphaCutoff: 0.05, }), ); } @@ -4131,7 +4868,7 @@ export function Map3D({ } } }, [ - ensureMercatorOverlays, + ensureMercatorOverlay, projection, overlayRef, projectionBusyRef, @@ -4143,6 +4880,12 @@ export function Map3D({ fleetCircles, legacyTargetsOrdered, legacyHits, + legacyOverlayTargets, + shipOverlayLayerData, + pairRangesInteractive, + pairLinksInteractive, + fcLinesInteractive, + fleetCirclesInteractive, overlays.pairRange, overlays.pairLines, overlays.fcLines, @@ -4161,216 +4904,6 @@ export function Map3D({ hasAuxiliarySelectModifier, ]); - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (projectionBusyRef.current) return; - - if (projection !== "mercator") { - try { - if (overlayInteractionRef.current) overlayInteractionRef.current.setProps({ layers: [] } as never); - } catch { - // ignore - } - return; - } - - const refs = ensureMercatorOverlays(); - const deckTarget = refs?.interaction; - if (!deckTarget) return; - - const overlayParams = DEPTH_DISABLED_PARAMS; - const overlayLayers: unknown[] = []; - - if (overlays.pairRange && pairRangesInteractive.length > 0) { - overlayLayers.push( - new ScatterplotLayer({ - id: "pair-range-overlay", - data: 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 ? [245, 158, 11, 220] : [245, 158, 11, 170]), - getPosition: (d) => d.center, - }), - ); - } - - if (overlays.pairLines && pairLinksInteractive.length > 0) { - overlayLayers.push( - new LineLayer({ - id: "pair-lines-overlay", - data: pairLinksInteractive, - pickable: false, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: () => [245, 158, 11, 245], - getWidth: () => 2.6, - widthUnits: "pixels", - }), - ); - } - - if (overlays.fcLines && fcLinesInteractive.length > 0) { - overlayLayers.push( - new LineLayer({ - id: "fc-lines-overlay", - data: fcLinesInteractive, - pickable: false, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: () => [245, 158, 11, 230], - getWidth: () => 1.9, - widthUnits: "pixels", - }), - ); - } - - if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { - overlayLayers.push( - new ScatterplotLayer({ - id: "fleet-circles-overlay-fill", - data: fleetCirclesInteractive, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - getFillColor: () => [245, 158, 11, 42], - }), - ); - overlayLayers.push( - new ScatterplotLayer({ - id: "fleet-circles-overlay", - data: fleetCirclesInteractive, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: "pixels", - getLineWidth: () => 1.8, - getLineColor: () => [245, 158, 11, 220], - getPosition: (d) => d.center, - }), - ); - } - - if (settings.showShips && legacyOverlayTargets.length > 0) { - overlayLayers.push( - new ScatterplotLayer({ - id: "legacy-halo-overlay", - data: legacyOverlayTargets, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; - return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; - }, - lineWidthUnits: "pixels", - getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), - getLineColor: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 210]; - return [rgb[0], rgb[1], rgb[2], 210]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - }), - ); - } - - if (settings.showShips && shipOverlayLayerData.length > 0) { - overlayLayers.push( - new IconLayer({ - id: "ships-overlay", - data: shipOverlayLayerData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - 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 (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => { - if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; - return getShipColor( - d, - selectedMmsi, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - shipHighlightSet, - ); - }, - }), - ); - } - - const normalizedLayers = sanitizeDeckLayerList(overlayLayers); - const overlayDeckProps = { - layers: normalizedLayers, - getTooltip: undefined, - onClick: undefined, - }; - - try { - deckTarget.setProps(overlayDeckProps as never); - } catch (e) { - console.error("Failed to apply interaction mercator deck props. Falling back to empty layer set.", e); - try { - deckTarget.setProps({ ...overlayDeckProps, layers: [] as unknown[] } as never); - } catch { - // Ignore secondary failure. - } - } - }, [ - ensureMercatorOverlays, - projection, - projectionBusyRef, - shipOverlayLayerData, - legacyOverlayTargets, - pairRangesInteractive, - pairLinksInteractive, - fcLinesInteractive, - fleetCirclesInteractive, - overlays.pairRange, - overlays.pairLines, - overlays.fcLines, - overlays.fleetCircles, - settings.showShips, - selectedMmsi, - shipHighlightSet, - legacyHits, - ]); - // Globe deck (3D) layer updates. Keep rendering logic deterministic and avoid per-frame churn. useEffect(() => { const map = mapRef.current; @@ -4396,7 +4929,11 @@ export function Map3D({ radiusMinPixels: 10, lineWidthUnits: "pixels", getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), - getLineColor: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? [245, 158, 11, 220] : [59, 130, 246, 110]), + getLineColor: (d) => { + const hl = isHighlightedPair(d.aMmsi, d.bMmsi); + if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; + return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; + }, getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { @@ -4424,7 +4961,11 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? [245, 158, 11, 245] : [59, 130, 246, 85]), + getColor: (d) => { + const hl = isHighlightedPair(d.aMmsi, d.bMmsi); + if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; + return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; + }, getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: "pixels", onHover: (info) => { @@ -4454,7 +4995,8 @@ export function Map3D({ getTargetPosition: (d) => d.to, getColor: (d) => { const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); - return isHighlighted ? [245, 158, 11, 230] : [217, 119, 6, 200]; + if (isHighlighted) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; + return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); @@ -4499,7 +5041,7 @@ export function Map3D({ getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: "pixels", getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), - getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? [245, 158, 11, 220] : [245, 158, 11, 140]), + getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), getPosition: (d) => d.center, onHover: (info) => { if (!info.object) { @@ -4528,7 +5070,7 @@ export function Map3D({ stroked: false, radiusUnits: "meters", getRadius: (d) => d.radiusNm * 1852, - getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? [245, 158, 11, 42] : [245, 158, 11, 6]), + getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), getPosition: (d) => d.center, }), ); @@ -4547,17 +5089,14 @@ export function Map3D({ radiusUnits: "pixels", getRadius: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; - if (isHighlightedMmsi(d.mmsi)) return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: "pixels", getLineWidth: (d) => { - const isHighlighted = isHighlightedMmsi(d.mmsi); - return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : isHighlighted ? 2.2 : 2; + return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2; }, getLineColor: (d) => { if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; - if (isHighlightedMmsi(d.mmsi)) return [245, 158, 11, 210]; const l = legacyHits?.get(d.mmsi); const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; if (!rgb) return [245, 158, 11, 200]; @@ -4568,90 +5107,6 @@ export function Map3D({ ); } - if (settings.showShips) { - globeLayers.push( - new IconLayer({ - id: "ships-globe", - data: shipLayerData, - pickable: true, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - 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 (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - return FLAT_SHIP_ICON_SIZE; - }, - getColor: (d) => - getShipColor( - d, - selectedMmsi, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - EMPTY_MMSI_SET, - ), - onHover: (info) => { - if (!info.object) { - clearDeckHoverPairs(); - clearDeckHoverMmsi(); - clearMapFleetHoverState(); - return; - } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - }, - alphaCutoff: 0.05, - }), - ); - } - - if (settings.showShips) { - globeLayers.push( - new IconLayer({ - id: "ships-globe-hover", - data: shipLayerData, - pickable: false, - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - 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 (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => { - if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; - return getShipColor( - d, - selectedMmsi, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - shipHighlightSet, - ); - }, - alphaCutoff: 0.05, - }), - ); - } - const normalizedLayers = sanitizeDeckLayerList(globeLayers); const globeDeckProps = { layers: normalizedLayers, @@ -4677,14 +5132,12 @@ export function Map3D({ fcDashed, fleetCircles, legacyTargetsOrdered, - shipLayerData, overlays.pairRange, overlays.pairLines, overlays.fcLines, overlays.fleetCircles, settings.showShips, selectedMmsi, - isHighlightedMmsi, isHighlightedFleet, isHighlightedPair, clearDeckHoverPairs, @@ -4696,7 +5149,6 @@ export function Map3D({ toFleetMmsiList, touchDeckHoverState, legacyHits, - highlightedMmsiSetCombined, ]); // When the selected MMSI changes due to external UI (e.g., list click), fly to it. diff --git a/apps/web/src/widgets/relations/RelationsPanel.tsx b/apps/web/src/widgets/relations/RelationsPanel.tsx index 063328e..2024522 100644 --- a/apps/web/src/widgets/relations/RelationsPanel.tsx +++ b/apps/web/src/widgets/relations/RelationsPanel.tsx @@ -2,6 +2,7 @@ import { useMemo, type MouseEvent } from "react"; import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; +import { OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette"; type FleetSortMode = "count" | "range"; @@ -161,8 +162,8 @@ export function RelationsPanel({ {dist.toFixed(2)}NM @@ -197,7 +198,7 @@ export function RelationsPanel({ > {fc.permitNo} - + {dist.toFixed(1)}NM {isSameOwner ? (