From 69d2538f51ef6adcd054653726b81360b5c700f6 Mon Sep 17 00:00:00 2001 From: Arturo Manzoli Date: Fri, 22 May 2026 12:25:31 -0300 Subject: [PATCH 1/5] base-station: add draggable map marker and context menus [drop] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cockpit had no way to anchor the operator's physical base station on the map, so any downstream feature that needed a station position — range arcs, offscreen indicators, signal hints — had nothing to hang off of. Add the foundation pieces: - a persistent `useBaseStationStore` holding position and enabled state plus reset/remove actions, - a draggable Leaflet marker rendered through a new `useBaseStationOverlay` composable, - a right-click `BaseStationContextPopup` for status and quick actions, - "Set / Move base station here" entries in the mission-planning and Map widget context menus. --- src/components/BaseStationContextPopup.vue | 156 ++++++++++++++++++ .../mission-planning/ContextMenu.vue | 53 ++++++ src/components/widgets/Map.vue | 77 +++++++++ .../baseStation/baseStationOverlay.css | 39 +++++ src/composables/baseStation/useBaseStation.ts | 96 +++++++++++ .../baseStation/useBaseStationOverlay.ts | 102 ++++++++++++ src/types/baseStation.ts | 17 ++ src/views/MissionPlanningView.vue | 16 ++ 8 files changed, 556 insertions(+) create mode 100644 src/components/BaseStationContextPopup.vue create mode 100644 src/composables/baseStation/baseStationOverlay.css create mode 100644 src/composables/baseStation/useBaseStation.ts create mode 100644 src/composables/baseStation/useBaseStationOverlay.ts create mode 100644 src/types/baseStation.ts diff --git a/src/components/BaseStationContextPopup.vue b/src/components/BaseStationContextPopup.vue new file mode 100644 index 0000000000..ec3bdb2c77 --- /dev/null +++ b/src/components/BaseStationContextPopup.vue @@ -0,0 +1,156 @@ + + + diff --git a/src/components/mission-planning/ContextMenu.vue b/src/components/mission-planning/ContextMenu.vue index 209942d77d..ea9bdd2b9c 100644 --- a/src/components/mission-planning/ContextMenu.vue +++ b/src/components/mission-planning/ContextMenu.vue @@ -175,6 +175,39 @@ Set home waypoint + + + {{ + baseStationStore.config.enabled ? 'Move base station here' : 'Set base station here' + }} + + + (null) /* eslint-disable jsdoc/require-jsdoc */ @@ -301,6 +336,9 @@ const emit = defineEmits<{ (event: 'placePointOfInterest'): void (event: 'setHomePosition'): void (event: 'clearVehiclePathHistory'): void + (event: 'placeBaseStation'): void + (event: 'configureBaseStation'): void + (event: 'removeBaseStation'): void }>() const menuType = computed(() => props.menuType) @@ -409,6 +447,21 @@ const handleClearVehiclePathHistory = (): void => { emit('close') } +const handlePlaceBaseStation = (): void => { + emit('placeBaseStation') + emit('close') +} + +const handleConfigureBaseStation = (): void => { + emit('configureBaseStation') + emit('close') +} + +const handleRemoveBaseStation = (): void => { + emit('removeBaseStation') + emit('close') +} + const onRegenerateSurveyWaypoints = (newAngle: number): void => { emit('regenerateSurveyWaypoints', newAngle) } diff --git a/src/components/widgets/Map.vue b/src/components/widgets/Map.vue index 9a33480c3c..a049dcca33 100644 --- a/src/components/widgets/Map.vue +++ b/src/components/widgets/Map.vue @@ -232,6 +232,7 @@

+ vehiclePosition.value) targetFollower.setTrackableTarget(WhoToFollow.HOME, () => home.value) +useBaseStationOverlay(map, mapReady) + // Calculate live vehicle position const vehiclePosition = computed(() => vehicleStore.coordinates.latitude @@ -1464,6 +1471,22 @@ const globalOriginLatitude = ref(0) const globalOriginLongitude = ref(0) const globalOriginMarker = shallowRef() +// Tag used to identify the base-station "place" entry so the watcher can rebind it +// on store changes without relying on label/icon string matching. +type BaseStationMenuTags = { + /* eslint-disable jsdoc/require-jsdoc */ + _isBaseStationPlace?: boolean + _isBaseStationContext?: boolean + /* eslint-enable jsdoc/require-jsdoc */ +} + +const baseStationMenuItem = computed(() => ({ + item: baseStationStore.config.enabled ? 'Move base station here' : 'Set base station here', + action: () => onMenuOptionSelect('place-base-station'), + icon: 'mdi-radio-tower', + _isBaseStationPlace: true, +})) + const menuItems = reactive([ { item: 'Set home waypoint', @@ -1480,6 +1503,7 @@ const menuItems = reactive([ action: () => onMenuOptionSelect('place-poi'), icon: 'mdi-map-marker-plus', }, + baseStationMenuItem.value, { item: 'GoTo', action: () => onMenuOptionSelect('goto'), icon: 'mdi-crosshairs-gps' }, { item: 'Set default map position', @@ -1493,6 +1517,44 @@ const menuItems = reactive([ }, ]) +// The base-station entry's label depends on whether one already exists; rebind on store changes. +watch(baseStationMenuItem, (newItem) => { + const idx = menuItems.findIndex((i) => (i as BaseStationMenuTags)._isBaseStationPlace === true) + if (idx >= 0) menuItems[idx] = newItem +}) + +const baseStationContextItems = computed(() => + baseStationStore.config.enabled + ? [ + { + item: 'Configure base station', + action: () => onMenuOptionSelect('configure-base-station'), + icon: 'mdi-cog', + _isBaseStationContext: true, + }, + { + item: 'Remove base station', + action: () => onMenuOptionSelect('remove-base-station'), + icon: 'mdi-radio-tower', + _isBaseStationContext: true, + }, + ] + : [] +) + +watch( + baseStationContextItems, + (newItems) => { + for (let i = menuItems.length - 1; i >= 0; i--) { + if ((menuItems[i] as BaseStationMenuTags)._isBaseStationContext) { + menuItems.splice(i, 1) + } + } + newItems.forEach((i) => menuItems.push(i)) + }, + { immediate: true } +) + const updateSkipToWpMenu = (): void => { const want = contextMenuSelectedWpIndex.value !== null const last = menuItems[menuItems.length - 1] as any @@ -1681,6 +1743,21 @@ const onMenuOptionSelect = async (option: string): Promise => { openSnackbar({ message: 'Vehicle path history cleared', variant: 'success' }) break + case 'place-base-station': + if (clickedLocation.value) { + baseStationStore.setPosition(clickedLocation.value) + baseStationStore.configPanelOpen = true + } + break + + case 'configure-base-station': + baseStationStore.configPanelOpen = true + break + + case 'remove-base-station': + confirmRemoveBaseStation(showDialog, closeDialog) + break + default: console.warn('Unknown menu option selected:', option) } diff --git a/src/composables/baseStation/baseStationOverlay.css b/src/composables/baseStation/baseStationOverlay.css new file mode 100644 index 0000000000..c33747e22b --- /dev/null +++ b/src/composables/baseStation/baseStationOverlay.css @@ -0,0 +1,39 @@ +/* Base-station overlay styles must be global because Leaflet's `divIcon` renders the markup + outside the consuming component's scoped boundary. They live with the overlay composable so + any view that mounts it gets the styles, instead of relying on an unrelated component. */ +.base-station-marker-icon { + background: none; + border: none; +} + +.base-station-marker-container { + position: relative; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; +} + +.base-station-marker-background { + position: absolute; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #005fad; + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + z-index: 1; +} + +.base-station-marker-label { + position: absolute; + bottom: -12px; + background-color: rgba(0, 0, 0, 0.7); + color: white; + font-size: 10px; + padding: 1px 4px; + border-radius: 2px; + white-space: nowrap; +} diff --git a/src/composables/baseStation/useBaseStation.ts b/src/composables/baseStation/useBaseStation.ts new file mode 100644 index 0000000000..24cc087ba0 --- /dev/null +++ b/src/composables/baseStation/useBaseStation.ts @@ -0,0 +1,96 @@ +import { reactive, ref } from 'vue' + +import type { DialogOptions, DialogResult } from '@/composables/interactionDialog' +import { useBlueOsStorage } from '@/composables/settingsSyncer' +import { type BaseStationConfig, DEFAULT_BASE_STATION_CONFIG } from '@/types/baseStation' +import type { DialogActions } from '@/types/general' +import type { WaypointCoordinates } from '@/types/mission' + +// eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/explicit-function-return-type -- type inferred for the reactive() output to keep per-state-field typing local to this file +function initialize() { + const config = useBlueOsStorage('cockpit-base-station-config', DEFAULT_BASE_STATION_CONFIG) + + // Merge defaults so newly-added fields are populated for existing users. + config.value = { ...DEFAULT_BASE_STATION_CONFIG, ...config.value } + + const configPanelOpen = ref(false) + + const contextPopupOpen = ref(false) + const contextPopupPosition = ref({ x: 0, y: 0 }) + + const openContextPopup = (x: number, y: number): void => { + contextPopupPosition.value = { x, y } + contextPopupOpen.value = true + } + + const closeContextPopup = (): void => { + contextPopupOpen.value = false + } + + const setPosition = (position: WaypointCoordinates): void => { + config.value.position = [Number(position[0].toFixed(8)), Number(position[1].toFixed(8))] + config.value.enabled = true + } + + const remove = (): void => { + config.value = { ...DEFAULT_BASE_STATION_CONFIG } + configPanelOpen.value = false + contextPopupOpen.value = false + } + + return reactive({ + config, + configPanelOpen, + contextPopupOpen, + contextPopupPosition, + openContextPopup, + closeContextPopup, + setPosition, + remove, + }) +} + +let api: ReturnType | null = null + +/** + * Singleton-style composable holding the base-station configuration, transient UI state + * (config panel / context popup / coverage controls), and the actions that mutate them. + * State is shared across all callers; the first call lazily initializes it so dependent + * stores (Pinia, BlueOS settings) are guaranteed to be ready. + * @returns {ReturnType} Reactive base-station state and actions. + */ +export const useBaseStation = (): ReturnType => { + if (!api) api = initialize() + return api +} + +/** + * Shows the shared confirmation dialog for removing the base station and clears it once confirmed. + * Centralizes the prompt so every entry point (context popup, config panel, map context menu) + * asks before the destructive, undo-less removal. + * @param {(options: DialogOptions) => Promise} showDialog - Opens the caller's interaction dialog. + * @param {() => void} closeDialog - Closes the caller's interaction dialog. + * @returns {void} + */ +export const confirmRemoveBaseStation = ( + showDialog: (options: DialogOptions) => Promise, + closeDialog: () => void +): void => { + showDialog({ + variant: 'text-only', + message: 'Remove the base station? This will clear its position and configuration.', + persistent: false, + maxWidth: '480px', + actions: [ + { text: 'Cancel', color: 'white', action: closeDialog }, + { + text: 'Remove', + color: 'white', + action: () => { + useBaseStation().remove() + closeDialog() + }, + }, + ] as DialogActions[], + }) +} diff --git a/src/composables/baseStation/useBaseStationOverlay.ts b/src/composables/baseStation/useBaseStationOverlay.ts new file mode 100644 index 0000000000..6332fa47a4 --- /dev/null +++ b/src/composables/baseStation/useBaseStationOverlay.ts @@ -0,0 +1,102 @@ +import './baseStationOverlay.css' + +import L from 'leaflet' +import { type Ref, type ShallowRef, onBeforeUnmount, shallowRef, watch } from 'vue' + +import { useBaseStation } from '@/composables/baseStation/useBaseStation' +import type { BaseStationConfig } from '@/types/baseStation' + +/* eslint-disable jsdoc/require-jsdoc -- internal helper return shape, name is self-describing. */ +type BaseStationOverlayApi = { openConfigPanel: () => void } +/* eslint-enable jsdoc/require-jsdoc */ + +const baseStationMarkerHtml = (label: string): string => ` +
+
+ +
${label}
+
+` + +/** + * Renders the base-station marker on a Leaflet map and keeps it in sync with the + * {@link useBaseStation} state. Mounting and unmounting are handled automatically. + * @param {ShallowRef} map Reactive reference to the Leaflet map instance. + * @param {Ref} mapReady Reactive flag that becomes true once the map is initialized. + * @returns {BaseStationOverlayApi} Helpers to drive the overlay from the host view. + */ +export const useBaseStationOverlay = ( + map: ShallowRef, + mapReady: Ref +): BaseStationOverlayApi => { + const store = useBaseStation() + + const marker = shallowRef() + + const openConfigPanel = (): void => { + store.configPanelOpen = true + } + + const removeLayer = (layer: L.Layer | undefined): void => { + if (layer && map.value) map.value.removeLayer(layer) + } + + const buildMarkerIcon = (): L.DivIcon => + L.divIcon({ + className: 'base-station-marker-icon', + html: baseStationMarkerHtml('Base'), + iconSize: [24, 24], + iconAnchor: [12, 12], + }) + + const ensureMarker = (config: BaseStationConfig): void => { + if (!map.value || !config.position) return + if (marker.value) { + marker.value.setLatLng(config.position) + return + } + const m = L.marker(config.position, { + icon: buildMarkerIcon(), + draggable: true, + zIndexOffset: 600, + // The marker owns its own right-click popup; don't propagate to the map context menu. + bubblingMouseEvents: false, + }) + m.on('drag', (event: L.LeafletEvent) => { + const target = event.target as L.Marker + const { lat, lng } = target.getLatLng() + store.setPosition([lat, lng]) + }) + m.on('contextmenu', (event: L.LeafletMouseEvent) => { + L.DomEvent.stopPropagation(event) + event.originalEvent.stopPropagation() + event.originalEvent.preventDefault() + store.openContextPopup(event.originalEvent.clientX, event.originalEvent.clientY) + }) + m.addTo(map.value) + marker.value = m + } + + const refreshAll = (): void => { + if (!mapReady.value || !(map.value instanceof L.Map)) return + const config = store.config + + if (!config.enabled || !config.position) { + removeLayer(marker.value) + marker.value = undefined + return + } + + ensureMarker(config) + } + + watch([map, mapReady], refreshAll, { immediate: true }) + watch(() => store.config, refreshAll, { deep: true }) + + onBeforeUnmount(() => { + removeLayer(marker.value) + marker.value = undefined + }) + + return { openConfigPanel } +} diff --git a/src/types/baseStation.ts b/src/types/baseStation.ts new file mode 100644 index 0000000000..c52293fcfc --- /dev/null +++ b/src/types/baseStation.ts @@ -0,0 +1,17 @@ +import type { WaypointCoordinates } from '@/types/mission' + +export type BaseStationConfig = { + /** + * Whether the base station is placed on the map. False until the user sets a position. + */ + enabled: boolean + /** + * Geographical position of the base station as [latitude, longitude]. + */ + position: WaypointCoordinates | null +} + +export const DEFAULT_BASE_STATION_CONFIG: BaseStationConfig = { + enabled: false, + position: null, +} diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index dfe3669030..31c31020e2 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -585,6 +585,9 @@ @place-point-of-interest="openPoiDialog" @add-waypoint-at-cursor="addWaypointFromContextMenu" @clear-vehicle-path-history="clearVehiclePathHistory" + @place-base-station="placeBaseStationFromContextMenu" + @configure-base-station="baseStationStore.configPanelOpen = true" + @remove-base-station="confirmRemoveBaseStation(showDialog, closeDialog)" /> + () const mapContext = provideMapContext() const { mapReady } = mapContext +useBaseStationOverlay(planningMap, mapReady) + const mapCenter = ref(missionStore.userLastMapCenter ?? missionStore.defaultMapCenter) const zoom = ref(missionStore.userLastMapZoom ?? missionStore.defaultMapZoom) const followerTarget = ref(undefined) @@ -1962,6 +1972,12 @@ const clearVehiclePathHistory = (): void => { openSnackbar({ message: 'Vehicle path history cleared', variant: 'success' }) } +const placeBaseStationFromContextMenu = (): void => { + if (!currentCursorGeoCoordinates.value) return + baseStationStore.setPosition(currentCursorGeoCoordinates.value) + baseStationStore.configPanelOpen = true +} + const setHomePosition = async (): Promise => { if (!currentCursorGeoCoordinates.value) return const newHome: [number, number] = [currentCursorGeoCoordinates.value[0], currentCursorGeoCoordinates.value[1]] From ff38f202a87012268b38f70fce8daadd90ace6b2 Mon Sep 17 00:00:00 2001 From: Arturo Manzoli Date: Fri, 22 May 2026 12:31:17 -0300 Subject: [PATCH 2/5] base-station: add config panel for tether and radio links [drop] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare position pin can't drive a meaningful coverage visualization — operators using a tether or a radio link have to express the link's characteristics (antenna type, gain, beamwidth, range, height, mast multiplier) before any range overlay can be drawn. - Add `BaseStationConfigPanel.vue` with tether-length and radio-link controls. - Extend `useBaseStationOverlay` with omni/sector coverage polygons, gradient steps, and a draggable bearing handle plus aiming arc. - Persist the new fields through `useBaseStationStore` and the `BaseStationConfig` type. --- src/components/BaseStationConfigPanel.vue | 793 ++++++++++++++++++ src/components/widgets/Map.vue | 2 + .../baseStation/baseStationOverlay.css | 15 + src/composables/baseStation/useBaseStation.ts | 89 +- .../baseStation/useBaseStationOverlay.ts | 274 +++++- src/types/baseStation.ts | 182 ++++ src/views/MissionPlanningView.vue | 4 +- 7 files changed, 1351 insertions(+), 8 deletions(-) create mode 100644 src/components/BaseStationConfigPanel.vue diff --git a/src/components/BaseStationConfigPanel.vue b/src/components/BaseStationConfigPanel.vue new file mode 100644 index 0000000000..3476813035 --- /dev/null +++ b/src/components/BaseStationConfigPanel.vue @@ -0,0 +1,793 @@ + + + + + diff --git a/src/components/widgets/Map.vue b/src/components/widgets/Map.vue index a049dcca33..4e8a3b9059 100644 --- a/src/components/widgets/Map.vue +++ b/src/components/widgets/Map.vue @@ -232,6 +232,7 @@

+ ((bearing % 360) + 360) % 360 + // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/explicit-function-return-type -- type inferred for the reactive() output to keep per-state-field typing local to this file function initialize() { const config = useBlueOsStorage('cockpit-base-station-config', DEFAULT_BASE_STATION_CONFIG) // Merge defaults so newly-added fields are populated for existing users. - config.value = { ...DEFAULT_BASE_STATION_CONFIG, ...config.value } + config.value = { + ...DEFAULT_BASE_STATION_CONFIG, + ...config.value, + antenna: { ...DEFAULT_BASE_STATION_CONFIG.antenna, ...(config.value.antenna ?? {}) }, + } const configPanelOpen = ref(false) + const interfaceStore = useAppInterfaceStore() + watch(configPanelOpen, (isOpen) => { + interfaceStore.configPanelVisible = isOpen + }) + const contextPopupOpen = ref(false) const contextPopupPosition = ref({ x: 0, y: 0 }) @@ -27,6 +46,14 @@ function initialize() { contextPopupOpen.value = false } + const showCoverage = computed( + () => + config.value.enabled && + config.value.position !== null && + (config.value.commsType === BaseStationCommsType.RadioLink || + config.value.commsType === BaseStationCommsType.Tethered) + ) + const setPosition = (position: WaypointCoordinates): void => { config.value.position = [Number(position[0].toFixed(8)), Number(position[1].toFixed(8))] config.value.enabled = true @@ -38,14 +65,68 @@ function initialize() { contextPopupOpen.value = false } + const resetAntennaToDefaults = (): void => { + const factory = ANTENNA_FACTORY_DEFAULTS[config.value.antenna.type] + config.value.antenna = { ...factory, bearing: config.value.antenna.bearing } + } + + const setAntennaType = (type: AntennaType): void => { + const factory = ANTENNA_FACTORY_DEFAULTS[type] + config.value.antenna = { ...factory, bearing: config.value.antenna.bearing } + } + + const setBearing = (bearing: number): void => { + config.value.antenna.bearing = normalizeBearing(bearing) + } + + let geoWatchId: number | null = null + const stopGeoWatch = (): void => { + if (geoWatchId !== null && navigator?.geolocation) { + navigator.geolocation.clearWatch(geoWatchId) + geoWatchId = null + } + } + const startGeoWatch = (): void => { + if (geoWatchId !== null || !navigator?.geolocation) return + geoWatchId = navigator.geolocation.watchPosition( + (position) => setPosition([position.coords.latitude, position.coords.longitude]), + (error) => { + openSnackbar({ + variant: 'error', + message: `Base station GPS tracking failed: ${error.message}. Disabling.`, + duration: 4000, + }) + config.value.trackByGps = false + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 1000 } + ) + } + + watch( + () => [config.value.trackByGps, config.value.enabled] as const, + ([tracking, enabled]) => { + if (tracking && enabled) startGeoWatch() + else stopGeoWatch() + }, + { immediate: true } + ) + + // This singleton never unmounts, so the watch above can't release the geolocation watch on a + // full app teardown; clear it on window unload to avoid leaking it across reloads. + if (typeof window !== 'undefined') window.addEventListener('beforeunload', stopGeoWatch) + return reactive({ config, configPanelOpen, contextPopupOpen, contextPopupPosition, + showCoverage, + setPosition, + setBearing, + setAntennaType, + resetAntennaToDefaults, openContextPopup, closeContextPopup, - setPosition, remove, }) } diff --git a/src/composables/baseStation/useBaseStationOverlay.ts b/src/composables/baseStation/useBaseStationOverlay.ts index 6332fa47a4..7efc74e760 100644 --- a/src/composables/baseStation/useBaseStationOverlay.ts +++ b/src/composables/baseStation/useBaseStationOverlay.ts @@ -1,10 +1,72 @@ import './baseStationOverlay.css' +import * as turf from '@turf/turf' import L from 'leaflet' import { type Ref, type ShallowRef, onBeforeUnmount, shallowRef, watch } from 'vue' import { useBaseStation } from '@/composables/baseStation/useBaseStation' -import type { BaseStationConfig } from '@/types/baseStation' +import { + type BaseStationConfig, + AntennaType, + BaseStationCommsType, + effectiveAntennaRangeMeters, +} from '@/types/baseStation' +import type { WaypointCoordinates } from '@/types/mission' + +const SECTOR_ARC_STEPS = 64 + +// Concentric coverage rings with decreasing radius. Stacking them at the same per-layer opacity +// produces a smooth radial fade that mimics the pattern published in BR's directional antenna +// guide while keeping the brightest band where the signal is strongest. +const COVERAGE_GRADIENT_STEPS = 12 +const COVERAGE_STEP_OPACITY = 0.045 + +const sectorPolygonLatLngs = ( + center: WaypointCoordinates, + rangeMeters: number, + bearingDeg: number, + beamwidthDeg: number +): L.LatLngExpression[] => { + const halfBeam = beamwidthDeg / 2 + const startBearing = bearingDeg - halfBeam + const endBearing = bearingDeg + halfBeam + const rangeKm = rangeMeters / 1000 + const turfCenter = turf.point([center[1], center[0]]) + + const arc = turf.lineArc(turfCenter, rangeKm, startBearing, endBearing, { steps: SECTOR_ARC_STEPS }) + const arcPoints = arc.geometry.coordinates.map(([lng, lat]) => [lat, lng] as L.LatLngExpression) + return [center as L.LatLngExpression, ...arcPoints, center as L.LatLngExpression] +} + +const bearingHandlePosition = ( + center: WaypointCoordinates, + rangeMeters: number, + bearingDeg: number +): WaypointCoordinates => { + const dest = turf.destination(turf.point([center[1], center[0]]), rangeMeters / 1000, bearingDeg, { + units: 'kilometers', + }) + const [lng, lat] = dest.geometry.coordinates + return [lat, lng] +} + +// 180° front-facing arc at the antenna's max range, used to preview where the signal will land +// as the operator rotates the antenna. +const aimingArcLatLngs = ( + center: WaypointCoordinates, + rangeMeters: number, + bearingDeg: number +): L.LatLngExpression[] => { + const turfCenter = turf.point([center[1], center[0]]) + const arc = turf.lineArc(turfCenter, rangeMeters / 1000, bearingDeg - 90, bearingDeg + 90, { + steps: SECTOR_ARC_STEPS, + }) + return arc.geometry.coordinates.map(([lng, lat]) => [lat, lng] as L.LatLngExpression) +} + +const bearingFromCenter = (center: WaypointCoordinates, point: WaypointCoordinates): number => { + return turf.bearing(turf.point([center[1], center[0]]), turf.point([point[1], point[0]])) +} /* eslint-disable jsdoc/require-jsdoc -- internal helper return shape, name is self-describing. */ type BaseStationOverlayApi = { openConfigPanel: () => void } @@ -19,8 +81,9 @@ const baseStationMarkerHtml = (label: string): string => ` ` /** - * Renders the base-station marker on a Leaflet map and keeps it in sync with the - * {@link useBaseStation} state. Mounting and unmounting are handled automatically. + * Renders the base-station marker, antenna coverage and tether circle on a Leaflet map and + * keeps them in sync with the {@link useBaseStation} state. Mounting and unmounting are + * handled automatically. * @param {ShallowRef} map Reactive reference to the Leaflet map instance. * @param {Ref} mapReady Reactive flag that becomes true once the map is initialized. * @returns {BaseStationOverlayApi} Helpers to drive the overlay from the host view. @@ -32,6 +95,13 @@ export const useBaseStationOverlay = ( const store = useBaseStation() const marker = shallowRef() + const coverageLayer = shallowRef() + const coverageSteps = shallowRef<(L.Circle | L.Polygon)[]>([]) + const coverageAntennaType = shallowRef() + const tetherLayer = shallowRef() + const bearingHandle = shallowRef() + const bearingLine = shallowRef() + const aimingArc = shallowRef() const openConfigPanel = (): void => { store.configPanelOpen = true @@ -49,10 +119,19 @@ export const useBaseStationOverlay = ( iconAnchor: [12, 12], }) + const buildBearingHandleIcon = (): L.DivIcon => + L.divIcon({ + className: 'base-station-bearing-handle', + html: '
', + iconSize: [18, 18], + iconAnchor: [9, 9], + }) + const ensureMarker = (config: BaseStationConfig): void => { if (!map.value || !config.position) return if (marker.value) { marker.value.setLatLng(config.position) + applyMarkerColor(config.coverageColor) return } const m = L.marker(config.position, { @@ -75,6 +154,172 @@ export const useBaseStationOverlay = ( }) m.addTo(map.value) marker.value = m + applyMarkerColor(config.coverageColor) + } + + // Marker stays fully visible regardless of the opacity slider — only the coverage and + // bearing/arc dotted lines fade with `coverageOpacity`. The trailing `cc` keeps the + // marker's original 80% fill so the icon underneath stays legible against the map. + const applyMarkerColor = (color: string): void => { + const el = marker.value?.getElement() + if (!el) return + const bg = el.querySelector('.base-station-marker-background') as HTMLElement | null + if (bg) bg.style.backgroundColor = `${color.slice(0, 7)}cc` + } + + const updateCoverage = (config: BaseStationConfig): void => { + if (!map.value || !store.showCoverage || !config.position || config.commsType !== BaseStationCommsType.RadioLink) { + removeLayer(coverageLayer.value) + coverageLayer.value = undefined + coverageSteps.value = [] + coverageAntennaType.value = undefined + return + } + + const position = config.position + const isOmni = config.antenna.type === AntennaType.Omni + const rangeMeters = effectiveAntennaRangeMeters(config) + const stepStyle = { + color: config.coverageColor, + weight: 0, + fillColor: config.coverageColor, + fillOpacity: COVERAGE_STEP_OPACITY * config.coverageOpacity, + interactive: false, + } + const stepRadius = (step: number): number => (rangeMeters * step) / COVERAGE_GRADIENT_STEPS + + // Recreating every gradient layer on each config change thrashes Leaflet during a bearing + // drag, so reuse the existing step layers in place and only rebuild when the shape changes. + const canUpdateInPlace = + coverageLayer.value !== undefined && + coverageAntennaType.value === config.antenna.type && + coverageSteps.value.length === COVERAGE_GRADIENT_STEPS + + if (canUpdateInPlace) { + coverageSteps.value.forEach((layer, index) => { + const radius = stepRadius(index + 1) + if (isOmni) { + const circle = layer as L.Circle + circle.setLatLng(position) + circle.setRadius(radius) + circle.setStyle(stepStyle) + } else { + const polygon = layer as L.Polygon + polygon.setLatLngs(sectorPolygonLatLngs(position, radius, config.antenna.bearing, config.antenna.beamwidth)) + polygon.setStyle(stepStyle) + } + }) + return + } + + removeLayer(coverageLayer.value) + const group = L.layerGroup() + const steps: (L.Circle | L.Polygon)[] = [] + for (let step = 1; step <= COVERAGE_GRADIENT_STEPS; step++) { + const radius = stepRadius(step) + const layer = isOmni + ? L.circle(position, { ...stepStyle, radius }) + : L.polygon(sectorPolygonLatLngs(position, radius, config.antenna.bearing, config.antenna.beamwidth), stepStyle) + layer.addTo(group) + steps.push(layer) + } + group.addTo(map.value) + coverageLayer.value = group + coverageSteps.value = steps + coverageAntennaType.value = config.antenna.type + } + + const updateTether = (config: BaseStationConfig): void => { + removeLayer(tetherLayer.value) + tetherLayer.value = undefined + + if (!map.value || !store.showCoverage || !config.position) return + if (config.commsType !== BaseStationCommsType.Tethered) return + + tetherLayer.value = L.circle(config.position, { + radius: config.tetherLengthMeters, + color: config.coverageColor, + weight: 1, + opacity: config.coverageOpacity, + fillColor: config.coverageColor, + fillOpacity: 0.1 * config.coverageOpacity, + dashArray: '4 4', + interactive: false, + }).addTo(map.value) + } + + const updateBearingHandle = (config: BaseStationConfig): void => { + const shouldShow = + map.value !== undefined && + config.position !== null && + config.commsType === BaseStationCommsType.RadioLink && + config.antenna.type !== AntennaType.Omni + + if (!shouldShow) { + removeLayer(bearingHandle.value) + removeLayer(bearingLine.value) + removeLayer(aimingArc.value) + bearingHandle.value = undefined + bearingLine.value = undefined + aimingArc.value = undefined + return + } + + const rangeMeters = effectiveAntennaRangeMeters(config) + const handleLatLng = bearingHandlePosition(config.position!, rangeMeters, config.antenna.bearing) + const lineLatLngs = [config.position!, handleLatLng] as L.LatLngExpression[] + const arcLatLngs = aimingArcLatLngs(config.position!, rangeMeters, config.antenna.bearing) + const lineOpacity = 0.3 * config.coverageOpacity + const arcOpacity = 0.25 * config.coverageOpacity + + if (bearingLine.value) { + bearingLine.value.setLatLngs(lineLatLngs) + bearingLine.value.setStyle({ color: config.coverageColor, opacity: lineOpacity }) + } else { + bearingLine.value = L.polyline(lineLatLngs, { + color: config.coverageColor, + weight: 1, + dashArray: '6 4', + opacity: lineOpacity, + interactive: false, + }).addTo(map.value!) + } + + if (aimingArc.value) { + aimingArc.value.setLatLngs(arcLatLngs) + aimingArc.value.setStyle({ color: config.coverageColor, opacity: arcOpacity }) + } else { + aimingArc.value = L.polyline(arcLatLngs, { + color: config.coverageColor, + weight: 1, + dashArray: '6 4', + opacity: arcOpacity, + interactive: false, + }).addTo(map.value!) + } + + // Update in place; recreating during drag would destroy the handle Leaflet is tracking + // and stop the rotation after a single drag step. + if (bearingHandle.value) { + bearingHandle.value.setLatLng(handleLatLng) + return + } + + const handle = L.marker(handleLatLng, { + icon: buildBearingHandleIcon(), + draggable: true, + zIndexOffset: 700, + bubblingMouseEvents: true, + }) + handle.on('drag', (event: L.LeafletEvent) => { + const center = store.config.position + if (!center) return + const target = event.target as L.Marker + const { lat, lng } = target.getLatLng() + store.setBearing(bearingFromCenter(center, [lat, lng])) + }) + handle.addTo(map.value!) + bearingHandle.value = handle } const refreshAll = (): void => { @@ -83,11 +328,24 @@ export const useBaseStationOverlay = ( if (!config.enabled || !config.position) { removeLayer(marker.value) + removeLayer(coverageLayer.value) + removeLayer(tetherLayer.value) + removeLayer(bearingHandle.value) + removeLayer(bearingLine.value) + removeLayer(aimingArc.value) marker.value = undefined + coverageLayer.value = undefined + tetherLayer.value = undefined + bearingHandle.value = undefined + bearingLine.value = undefined + aimingArc.value = undefined return } ensureMarker(config) + updateCoverage(config) + updateTether(config) + updateBearingHandle(config) } watch([map, mapReady], refreshAll, { immediate: true }) @@ -95,7 +353,17 @@ export const useBaseStationOverlay = ( onBeforeUnmount(() => { removeLayer(marker.value) + removeLayer(coverageLayer.value) + removeLayer(tetherLayer.value) + removeLayer(bearingHandle.value) + removeLayer(bearingLine.value) + removeLayer(aimingArc.value) marker.value = undefined + coverageLayer.value = undefined + tetherLayer.value = undefined + bearingHandle.value = undefined + bearingLine.value = undefined + aimingArc.value = undefined }) return { openConfigPanel } diff --git a/src/types/baseStation.ts b/src/types/baseStation.ts index c52293fcfc..c4d6cb7f0f 100644 --- a/src/types/baseStation.ts +++ b/src/types/baseStation.ts @@ -1,5 +1,65 @@ import type { WaypointCoordinates } from '@/types/mission' +/** + * Topside computer mount type. Mobile is typically a laptop or tablet, fixed is a stationary + * computer at the base station. + */ +export enum TopSideComputerType { + Mobile = 'Mobile', + Fixed = 'Fixed', +} + +/** + * Communication link type between the topside (base station) and the vehicle. + */ +export enum BaseStationCommsType { + Tethered = 'Tethered', + MobileData = 'Mobile data (4G/5G)', + RadioLink = 'Radio link', +} + +/** + * Radio base station product family, used to seed antenna defaults. + */ +export enum RadioBaseStationKind { + BlueRobotics = 'Blue Robotics BaseStation', + Custom = 'Custom', +} + +/** + * Antenna form factor. Determines the shape of the coverage drawn on the map (full circle + * for omni, sector/cone for directional ones) and seeds gain/beamwidth/range defaults. + */ +export enum AntennaType { + Omni = 'Omnidirectional', + Panel = 'Panel', + Yagi = 'Yagi', +} + +export type AntennaSpec = { + /** + * Antenna form factor. + */ + type: AntennaType + /** + * Antenna gain in dBi. + */ + gain: number + /** + * Horizontal beamwidth in degrees. Use 360 for omnidirectional antennas. + */ + beamwidth: number + /** + * Practical communication range in meters used to draw the coverage on the map. + */ + range: number + /** + * Bearing/azimuth of the antenna boresight in degrees, where 0 = north and angles grow clockwise. + * Ignored for omnidirectional antennas. + */ + bearing: number +} + export type BaseStationConfig = { /** * Whether the base station is placed on the map. False until the user sets a position. @@ -9,9 +69,131 @@ export type BaseStationConfig = { * Geographical position of the base station as [latitude, longitude]. */ position: WaypointCoordinates | null + /** + * Topside computer mount type. + */ + topSideComputerType: TopSideComputerType + /** + * Whether to keep the base-station position synced with the operator's GPS (browser Geolocation API). + */ + trackByGps: boolean + /** + * Communication link type between the topside and the vehicle. + */ + commsType: BaseStationCommsType + /** + * Radio base station product family. Only used when {@link commsType} is RadioLink. + */ + radioBaseStationKind: RadioBaseStationKind + /** + * Antenna parameters used to draw coverage. Only used when {@link commsType} is RadioLink. + */ + antenna: AntennaSpec + /** + * Height of the base-station antenna above ground in meters. Map coverage scales with √height using + * the radio-horizon model (see {@link baseStationAntennaHeightRangeMultiplier}). + */ + baseStationAntennaHeightMeters: number + /** + * Whether the vehicle mounts the BlueBoat Antenna and Accessory Mast (vehicle-side extender). + * When true, map coverage uses {@link BLUEBOAT_ANTENNA_MAST_RANGE_MULTIPLIER}× the entered range. + */ + vehicleHasBlueBoatAntennaMast: boolean + /** + * Tether length in meters used to draw coverage. Only used when {@link commsType} is Tethered. + */ + tetherLengthMeters: number + /** + * Transmitter power in milliwatts. Drives a Friis-based range scaling + * (range ∝ √P_t) when the operator picks a Custom radio. + */ + txPowerMilliwatts: number + /** + * Hex color used to render the projected antenna signal coverage on the map. + */ + coverageColor: string + /** + * Multiplier (0..1) applied to the coverage fill opacity. 1.0 keeps the default look, 0 makes it + * invisible. + */ + coverageOpacity: number +} + +/** + * Factory antenna specs derived from the BlueBoat BaseStation and Directional Antenna Kit guides. + * Range values are the practical (rest-of-world) ranges from BR's tested data; gain values are + * the rest-of-world recommended values that account for connector loss. + * + * Sources: bluerobotics.com/store/boat/blueboat-components-spares/basestation and + * bluerobotics.com/learn/directional-antenna-guide. + */ +export const ANTENNA_FACTORY_DEFAULTS: Record> = { + [AntennaType.Omni]: { type: AntennaType.Omni, gain: 7, beamwidth: 360, range: 250 }, + [AntennaType.Panel]: { type: AntennaType.Panel, gain: 12, beamwidth: 40, range: 500 }, + [AntennaType.Yagi]: { type: AntennaType.Yagi, gain: 16, beamwidth: 25, range: 800 }, +} + +/** + * BlueRobotics BaseStation TX power (Microhard pMDDL/pDDL 900 MHz, 1 W max). + */ +export const BLUE_ROBOTICS_TX_POWER_MW = 1000 + +/** + * Communication range multiplier with the BlueBoat Antenna and Accessory Mast on the vehicle. + * @see https://bluerobotics.com/store/boat/blueboat-accessories/blueboat-antenna-and-accessory-mast/ + */ +export const BLUEBOAT_ANTENNA_MAST_RANGE_MULTIPLIER = 1.75 + +/** + * Reference base-station antenna height (m). Factory range values assume roughly this height on a + * typical tripod or short mast. + */ +export const DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS = 1 + +const MIN_BASE_STATION_ANTENNA_HEIGHT_METERS = 0.5 +const MAX_BASE_STATION_ANTENNA_HEIGHT_METERS = 50 + +/** + * Range multiplier from base-station antenna height. Over flat water/terrain, radio horizon distance + * scales as √h (d_km ≈ 4.12·√h with standard 4/3 Earth-radius refraction), so practical range grows + * with the square root of height relative to {@link DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS}. + * @param {number} heightMeters Antenna height above ground in meters. + * @returns {number} Multiplier applied to the entered range for map coverage. + */ +export const baseStationAntennaHeightRangeMultiplier = (heightMeters: number): number => { + const clamped = Math.min( + MAX_BASE_STATION_ANTENNA_HEIGHT_METERS, + Math.max(MIN_BASE_STATION_ANTENNA_HEIGHT_METERS, heightMeters) + ) + return Math.sqrt(clamped / DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS) +} + +/** + * Practical antenna range (m) used for map coverage, including height and vehicle mast adjustments. + * @param {BaseStationConfig} config Current base-station configuration. + * @returns {number} Range in meters for overlay geometry. + */ +export const effectiveAntennaRangeMeters = (config: BaseStationConfig): number => { + if (config.commsType !== BaseStationCommsType.RadioLink) return config.antenna.range + + let range = config.antenna.range * baseStationAntennaHeightRangeMultiplier(config.baseStationAntennaHeightMeters) + if (config.vehicleHasBlueBoatAntennaMast) range *= BLUEBOAT_ANTENNA_MAST_RANGE_MULTIPLIER + + return Math.max(1, Math.round(range)) } export const DEFAULT_BASE_STATION_CONFIG: BaseStationConfig = { enabled: false, position: null, + topSideComputerType: TopSideComputerType.Fixed, + trackByGps: false, + commsType: BaseStationCommsType.RadioLink, + radioBaseStationKind: RadioBaseStationKind.BlueRobotics, + antenna: { ...ANTENNA_FACTORY_DEFAULTS[AntennaType.Omni], bearing: 0 }, + baseStationAntennaHeightMeters: DEFAULT_BASE_STATION_ANTENNA_HEIGHT_METERS, + vehicleHasBlueBoatAntennaMast: false, + tetherLengthMeters: 150, + txPowerMilliwatts: BLUE_ROBOTICS_TX_POWER_MW, + coverageColor: '#3B82F6', + coverageOpacity: 1, } diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index 31c31020e2..8889a9e69d 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -106,7 +106,7 @@ />
@@ -620,6 +620,7 @@ + Date: Fri, 22 May 2026 12:32:42 -0300 Subject: [PATCH 3/5] base-station: add mobile data coverage overlays to config panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tether and radio aren't the only links operators use — vehicles often fall back to a mobile-data uplink, and Cockpit had no way to show where cellular coverage existed around the station. Introduce a `MobileData` comms type with OpenCellID, OSM Overpass and custom-tile providers plus an operator filter, and add the initial overlay that fetches cellular sites around the configured base-station position. Store, type and panel sections are extended to match. --- src/components/BaseStationConfigPanel.vue | 79 +++++ src/composables/baseStation/useBaseStation.ts | 9 + .../baseStation/useBaseStationOverlay.ts | 274 +++++++++++++++++- src/types/baseStation.ts | 40 +++ src/types/shims.d.ts | 1 + 5 files changed, 402 insertions(+), 1 deletion(-) diff --git a/src/components/BaseStationConfigPanel.vue b/src/components/BaseStationConfigPanel.vue index 3476813035..681ee5723c 100644 --- a/src/components/BaseStationConfigPanel.vue +++ b/src/components/BaseStationConfigPanel.vue @@ -89,10 +89,56 @@
+
+

Coverage

+ +
+
+

API key

+ +
+
+

Operator

+ +
+
+

Tile URL

+ +
+ + + Custom coverage tile URL + + + + + + Cancel + Save + + + + store.config) @@ -444,11 +493,16 @@ const topSideTypes = Object.values(TopSideComputerType) const commsTypes = Object.values(BaseStationCommsType) const radioKinds = Object.values(RadioBaseStationKind) const antennaTypes = Object.values(AntennaType) +const mobileCoverageProviders = Object.values(MobileCoverageProvider) const isRadioLink = computed(() => config.value.commsType === BaseStationCommsType.RadioLink) const isTethered = computed(() => config.value.commsType === BaseStationCommsType.Tethered) +const isMobileData = computed(() => config.value.commsType === BaseStationCommsType.MobileData) const isOmni = computed(() => config.value.antenna.type === AntennaType.Omni) const isCustomRadio = computed(() => config.value.radioBaseStationKind === RadioBaseStationKind.Custom) +const isOpenCellId = computed(() => config.value.mobileCoverage.provider === MobileCoverageProvider.OpenCellID) +const isOsmOverpass = computed(() => config.value.mobileCoverage.provider === MobileCoverageProvider.OSMOverpass) +const isCustomCoverage = computed(() => config.value.mobileCoverage.provider === MobileCoverageProvider.Custom) const latText = ref('') const lngText = ref('') @@ -563,6 +617,26 @@ const onRadioKindChange = (event: Event): void => { } const estimatesInfoDialogOpen = ref(false) +const customCoverageDialogOpen = ref(false) +const customTileUrlDraft = ref('') + +const openCustomCoverageDialog = (): void => { + customTileUrlDraft.value = config.value.mobileCoverage.customTileUrl + customCoverageDialogOpen.value = true +} + +const saveCustomTileUrl = (): void => { + config.value.mobileCoverage.customTileUrl = customTileUrlDraft.value.trim() + customCoverageDialogOpen.value = false +} + +// Selecting Custom auto-pops the URL dialog (operator hasn't given us a tile URL yet), +// matching the spec; the other providers just swap and let the overlay refresh. +const onCoverageProviderChange = (event: Event): void => { + const next = (event.target as HTMLSelectElement).value as MobileCoverageProvider + config.value.mobileCoverage.provider = next + if (next === MobileCoverageProvider.Custom) openCustomCoverageDialog() +} const onAntennaTypeChange = (event: Event): void => { const value = (event.target as HTMLSelectElement).value as AntennaType @@ -735,6 +809,11 @@ const getMarginsFromBarsHeight = computed(() => { padding: 0; } +.config-text-btn { + text-align: center; + cursor: pointer; +} + /* Eyedropper hijacks the v-menu's outside-click and the OS picker UX is shaky on web; drop it. */ .base-station-color-picker :deep(.v-color-picker-preview__eye-dropper) { display: none; diff --git a/src/composables/baseStation/useBaseStation.ts b/src/composables/baseStation/useBaseStation.ts index 16394897e8..5023e716d5 100644 --- a/src/composables/baseStation/useBaseStation.ts +++ b/src/composables/baseStation/useBaseStation.ts @@ -25,8 +25,16 @@ function initialize() { ...DEFAULT_BASE_STATION_CONFIG, ...config.value, antenna: { ...DEFAULT_BASE_STATION_CONFIG.antenna, ...(config.value.antenna ?? {}) }, + mobileCoverage: { + ...DEFAULT_BASE_STATION_CONFIG.mobileCoverage, + ...(config.value.mobileCoverage ?? {}), + }, } + // Operators discovered in the most recent Overpass response. Populates the panel selector + // dynamically since the OSM `operator` tag varies wildly between regions. + const availableOsmOperators = ref([]) + const configPanelOpen = ref(false) const interfaceStore = useAppInterfaceStore() @@ -120,6 +128,7 @@ function initialize() { configPanelOpen, contextPopupOpen, contextPopupPosition, + availableOsmOperators, showCoverage, setPosition, setBearing, diff --git a/src/composables/baseStation/useBaseStationOverlay.ts b/src/composables/baseStation/useBaseStationOverlay.ts index 7efc74e760..2ffe728d32 100644 --- a/src/composables/baseStation/useBaseStationOverlay.ts +++ b/src/composables/baseStation/useBaseStationOverlay.ts @@ -5,14 +5,50 @@ import L from 'leaflet' import { type Ref, type ShallowRef, onBeforeUnmount, shallowRef, watch } from 'vue' import { useBaseStation } from '@/composables/baseStation/useBaseStation' +import { openSnackbar } from '@/composables/snackbar' import { type BaseStationConfig, AntennaType, BaseStationCommsType, effectiveAntennaRangeMeters, + MobileCoverageProvider, } from '@/types/baseStation' import type { WaypointCoordinates } from '@/types/mission' +/* eslint-disable jsdoc/require-jsdoc -- `leaflet.heat` ships no typings; this is a minimal augmentation. */ +declare module 'leaflet' { + type HeatLatLngTuple = [number, number, number] + interface HeatMapOptions { + minOpacity?: number + maxZoom?: number + max?: number + radius?: number + blur?: number + gradient?: Record + } + interface HeatLayer extends Layer { + setOptions(options: HeatMapOptions): HeatLayer + addLatLng(latlng: LatLng | HeatLatLngTuple): HeatLayer + setLatLngs(latlngs: Array): HeatLayer + } + function heatLayer(latlngs: Array, options?: HeatMapOptions): HeatLayer +} +/* eslint-enable jsdoc/require-jsdoc */ + +// `leaflet.heat` is a UMD-style plugin: its IIFE assigns `L.HeatLayer = …` against whatever +// `L` it finds on the scope chain — i.e. `window.L`. With ESM imports leaflet doesn't auto- +// publish to the global, so the plugin's IIFE silently no-ops and `L.heatLayer` ends up +// undefined. Expose `L` on `window` first, then load the plugin lazily on first use. +let heatLayerLoader: Promise | null = null +const ensureHeatLayer = (): Promise => { + if (heatLayerLoader) return heatLayerLoader + heatLayerLoader = (async () => { + Object.assign(window, { L }) + await import('leaflet.heat') + })() + return heatLayerLoader +} + const SECTOR_ARC_STEPS = 64 // Concentric coverage rings with decreasing radius. Stacking them at the same per-layer opacity @@ -68,10 +104,137 @@ const bearingFromCenter = (center: WaypointCoordinates, point: WaypointCoordinat return turf.bearing(turf.point([center[1], center[0]]), turf.point([point[1], point[0]])) } -/* eslint-disable jsdoc/require-jsdoc -- internal helper return shape, name is self-describing. */ +/* eslint-disable jsdoc/require-jsdoc -- Inline transport DTOs; field meanings follow upstream docs. */ + +// OSM Overpass has no per-call area cap, so we use a single ~11 km half-side bbox. +const OVERPASS_BBOX_DEG = 0.1 +// OpenCellID's getInArea caps at 4 km² per call (`code: 3` otherwise), so we tile a 3×3 grid +// of small boxes around the base station — ~5 km × 5 km of total coverage in 9 parallel calls. +const OPENCELLID_TILE_HALF_DEG = 0.0075 +const OPENCELLID_TILES_PER_SIDE = 3 +// `range` is reported in meters; this anchor (~5 km) maps a typical urban tower reach to +// full intensity in the heatmap. +const OPENCELLID_RANGE_NORMALIZER_M = 5000 + +type CoverageBbox = { south: number; west: number; north: number; east: number } + +const overpassBboxAround = (lat: number, lng: number): CoverageBbox => ({ + south: lat - OVERPASS_BBOX_DEG, + west: lng - OVERPASS_BBOX_DEG, + north: lat + OVERPASS_BBOX_DEG, + east: lng + OVERPASS_BBOX_DEG, +}) + +type OpenCellIdResponse = { + cells?: Array<{ lat: number; lon: number; range?: number }> + error?: string + code?: number +} + +const fetchOpenCellIdTile = async ( + bbox: CoverageBbox, + apiKey: string, + signal: AbortSignal +): Promise => { + const url = + `https://opencellid.org/cell/getInArea?key=${encodeURIComponent(apiKey)}` + + `&BBOX=${bbox.south},${bbox.west},${bbox.north},${bbox.east}` + + `&format=json&radio=LTE,NR&limit=1000` + const res = await fetch(url, { signal }) + if (!res.ok) throw new Error(`OpenCellID HTTP ${res.status}`) + const data = (await res.json()) as OpenCellIdResponse + // `code: 1` ("No cells found") is HTTP 200 + an `error` field; treat as empty, not failure. + if (data.error && data.code !== 1) throw new Error(`OpenCellID: ${data.error}`) + return (data.cells ?? []).map((c) => [ + c.lat, + c.lon, + Math.min(1, (c.range ?? 1000) / OPENCELLID_RANGE_NORMALIZER_M), + ]) +} + +const fetchOpenCellIdPoints = async ( + center: WaypointCoordinates, + apiKey: string, + signal: AbortSignal +): Promise => { + const [lat, lng] = center + const tileEdge = OPENCELLID_TILE_HALF_DEG * 2 + const offsetBase = (OPENCELLID_TILES_PER_SIDE - 1) / 2 + const tiles: CoverageBbox[] = [] + for (let i = 0; i < OPENCELLID_TILES_PER_SIDE; i++) { + for (let j = 0; j < OPENCELLID_TILES_PER_SIDE; j++) { + const cLat = lat + (i - offsetBase) * tileEdge + const cLng = lng + (j - offsetBase) * tileEdge + tiles.push({ + south: cLat - OPENCELLID_TILE_HALF_DEG, + west: cLng - OPENCELLID_TILE_HALF_DEG, + north: cLat + OPENCELLID_TILE_HALF_DEG, + east: cLng + OPENCELLID_TILE_HALF_DEG, + }) + } + } + const settled = await Promise.allSettled(tiles.map((b) => fetchOpenCellIdTile(b, apiKey, signal))) + const fulfilled = settled.filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + // If every tile failed, surface the first error so the operator gets a real diagnostic + // (invalid key, network down, …) instead of a silent empty heatmap. + if (fulfilled.length === 0) { + const firstReject = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected') + if (firstReject) throw firstReject.reason + } + return fulfilled.flatMap((r) => r.value) +} + +type OverpassResponse = { + elements?: Array<{ lat?: number; lon?: number; tags?: Record }> +} + +type OverpassTower = { lat: number; lon: number; operator: string | null } + +const fetchOverpassTowers = async (bbox: CoverageBbox, signal: AbortSignal): Promise => { + const region = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})` + const query = + `[out:json][timeout:25];` + + `(node["man_made"="communications_tower"]${region};` + + `node["tower:type"="communication"]${region};);` + + // `out body` (= `out;`) is required to keep node lat/lon. `out tags;` drops coords. + `out body;` + const res = await fetch('https://overpass-api.de/api/interpreter', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: query, + signal, + }) + if (!res.ok) throw new Error(`Overpass HTTP ${res.status}`) + const data = (await res.json()) as OverpassResponse + return (data.elements ?? []) + .filter( + (e): e is { lat: number; lon: number; tags?: Record } => + typeof e.lat === 'number' && typeof e.lon === 'number' + ) + .map((e) => ({ lat: e.lat, lon: e.lon, operator: e.tags?.operator?.trim() || null })) +} + +// Real cellular sites have ~1-3 km of usable urban coverage; pick a middle value and let the +// heatmap pixel radius track this in real meters as the operator zooms in/out. +const HEAT_COVERAGE_METERS = 1500 +const HEAT_RADIUS_MIN_PX = 30 +const HEAT_RADIUS_MAX_PX = 140 +// Equator meters-per-pixel at zoom 0. Halves with each zoom level; multiplied by cos(lat) to +// account for the Mercator projection narrowing toward the poles. +const METERS_PER_PIXEL_AT_Z0 = 156543.03392 + +type HeatRadius = { radius: number; blur: number } type BaseStationOverlayApi = { openConfigPanel: () => void } /* eslint-enable jsdoc/require-jsdoc */ +const heatRadiusForMap = (mapInstance: L.Map): HeatRadius => { + const center = mapInstance.getCenter() + const metersPerPixel = (METERS_PER_PIXEL_AT_Z0 * Math.cos((center.lat * Math.PI) / 180)) / 2 ** mapInstance.getZoom() + const ideal = HEAT_COVERAGE_METERS / metersPerPixel + const radius = Math.max(HEAT_RADIUS_MIN_PX, Math.min(HEAT_RADIUS_MAX_PX, ideal)) + return { radius, blur: radius * 0.6 } +} + const baseStationMarkerHtml = (label: string): string => `
@@ -102,6 +265,11 @@ export const useBaseStationOverlay = ( const bearingHandle = shallowRef() const bearingLine = shallowRef() const aimingArc = shallowRef() + const mobileCoverageLayer = shallowRef() + + let mobileCoverageController: AbortController | null = null + let mobileCoverageDebounce: ReturnType | null = null + let heatZoomCleanup: (() => void) | null = null const openConfigPanel = (): void => { store.configPanelOpen = true @@ -322,6 +490,86 @@ export const useBaseStationOverlay = ( bearingHandle.value = handle } + const teardownMobileCoverage = (): void => { + if (mobileCoverageController) { + mobileCoverageController.abort() + mobileCoverageController = null + } + if (heatZoomCleanup) { + heatZoomCleanup() + heatZoomCleanup = null + } + removeLayer(mobileCoverageLayer.value) + mobileCoverageLayer.value = undefined + } + + const updateMobileCoverage = async (config: BaseStationConfig): Promise => { + teardownMobileCoverage() + + if (!(map.value instanceof L.Map) || !config.enabled || !config.position) return + if (config.commsType !== BaseStationCommsType.MobileData) return + + const provider = config.mobileCoverage.provider + + if (provider === MobileCoverageProvider.Custom) { + const url = config.mobileCoverage.customTileUrl.trim() + if (!url) return + mobileCoverageLayer.value = L.tileLayer(url, { opacity: 0.55 }).addTo(map.value) + return + } + + const controller = new AbortController() + mobileCoverageController = controller + try { + let points: L.HeatLatLngTuple[] + if (provider === MobileCoverageProvider.OpenCellID) { + const apiKey = config.mobileCoverage.openCellIdApiKey.trim() + if (!apiKey) return + points = await fetchOpenCellIdPoints(config.position, apiKey, controller.signal) + } else { + const towers = await fetchOverpassTowers( + overpassBboxAround(config.position[0], config.position[1]), + controller.signal + ) + if (controller.signal.aborted) return + // Surface the operator vocabulary actually present in the bbox so the panel can render + // a meaningful selector (the OSM `operator` tag is region-specific). + store.availableOsmOperators = [...new Set(towers.map((t) => t.operator).filter((o): o is string => !!o))].sort() + const selectedOperator = config.mobileCoverage.osmOperator + const filtered = selectedOperator ? towers.filter((t) => t.operator === selectedOperator) : towers + points = filtered.map((t) => [t.lat, t.lon, 1]) + } + if (controller.signal.aborted || !map.value) return + if (points.length === 0) { + openSnackbar({ + variant: 'info', + message: `${provider} returned no cellular data around the base station.`, + duration: 4000, + }) + return + } + await ensureHeatLayer() + if (controller.signal.aborted || !map.value) return + const heat = L.heatLayer(points, { ...heatRadiusForMap(map.value), minOpacity: 0.25 }).addTo(map.value) + mobileCoverageLayer.value = heat + // Re-tune pixel radius on zoom so the visual stays anchored to ~1.5 km of real coverage. + const onZoom = (): void => { + if (map.value) heat.setOptions(heatRadiusForMap(map.value)) + } + map.value.on('zoomend', onZoom) + heatZoomCleanup = () => map.value?.off('zoomend', onZoom) + } catch (err) { + if ((err as DOMException)?.name === 'AbortError') return + openSnackbar({ + variant: 'error', + message: `Mobile coverage fetch failed: ${(err as Error).message}`, + duration: 4000, + }) + } finally { + if (mobileCoverageController === controller) mobileCoverageController = null + } + } + const refreshAll = (): void => { if (!mapReady.value || !(map.value instanceof L.Map)) return const config = store.config @@ -333,6 +581,7 @@ export const useBaseStationOverlay = ( removeLayer(bearingHandle.value) removeLayer(bearingLine.value) removeLayer(aimingArc.value) + teardownMobileCoverage() marker.value = undefined coverageLayer.value = undefined tetherLayer.value = undefined @@ -351,7 +600,30 @@ export const useBaseStationOverlay = ( watch([map, mapReady], refreshAll, { immediate: true }) watch(() => store.config, refreshAll, { deep: true }) + // Debounced so live edits to API key / tile URL don't hammer the public APIs on every keystroke. + watch( + () => [ + mapReady.value, + store.config.commsType, + store.config.mobileCoverage.provider, + store.config.mobileCoverage.openCellIdApiKey, + store.config.mobileCoverage.customTileUrl, + store.config.mobileCoverage.osmOperator, + store.config.position, + ], + () => { + if (mobileCoverageDebounce) clearTimeout(mobileCoverageDebounce) + mobileCoverageDebounce = setTimeout(() => updateMobileCoverage(store.config), 500) + }, + { immediate: true } + ) + onBeforeUnmount(() => { + if (mobileCoverageDebounce) { + clearTimeout(mobileCoverageDebounce) + mobileCoverageDebounce = null + } + teardownMobileCoverage() removeLayer(marker.value) removeLayer(coverageLayer.value) removeLayer(tetherLayer.value) diff --git a/src/types/baseStation.ts b/src/types/baseStation.ts index c4d6cb7f0f..06e1c3a27e 100644 --- a/src/types/baseStation.ts +++ b/src/types/baseStation.ts @@ -36,6 +36,36 @@ export enum AntennaType { Yagi = 'Yagi', } +/** + * Source of the cellular coverage overlay shown when {@link BaseStationCommsType.MobileData} is selected. + */ +export enum MobileCoverageProvider { + OpenCellID = 'OpenCellID', + OSMOverpass = 'OSM Overpass', + Custom = 'Custom overlay', +} + +export type MobileCoverageConfig = { + /** + * Active coverage data provider. + */ + provider: MobileCoverageProvider + /** + * OpenCellID API key. Required when {@link provider} is {@link MobileCoverageProvider.OpenCellID}. + */ + openCellIdApiKey: string + /** + * Leaflet `TileLayer` URL template (with `{z}/{x}/{y}` placeholders). Required when + * {@link provider} is {@link MobileCoverageProvider.Custom}. + */ + customTileUrl: string + /** + * OSM operator name to filter by when {@link provider} is {@link MobileCoverageProvider.OSMOverpass}. + * Empty string keeps all operators. + */ + osmOperator: string +} + export type AntennaSpec = { /** * Antenna form factor. @@ -103,6 +133,10 @@ export type BaseStationConfig = { * Tether length in meters used to draw coverage. Only used when {@link commsType} is Tethered. */ tetherLengthMeters: number + /** + * Cellular coverage overlay configuration. Only used when {@link commsType} is MobileData. + */ + mobileCoverage: MobileCoverageConfig /** * Transmitter power in milliwatts. Drives a Friis-based range scaling * (range ∝ √P_t) when the operator picks a Custom radio. @@ -194,6 +228,12 @@ export const DEFAULT_BASE_STATION_CONFIG: BaseStationConfig = { vehicleHasBlueBoatAntennaMast: false, tetherLengthMeters: 150, txPowerMilliwatts: BLUE_ROBOTICS_TX_POWER_MW, + mobileCoverage: { + provider: MobileCoverageProvider.OpenCellID, + openCellIdApiKey: '', + customTileUrl: '', + osmOperator: '', + }, coverageColor: '#3B82F6', coverageOpacity: 1, } diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts index aeb2ede488..77da3fd628 100644 --- a/src/types/shims.d.ts +++ b/src/types/shims.d.ts @@ -11,6 +11,7 @@ interface Navigator { declare module '@vue-leaflet/vue-leaflet' declare module 'gamepad.js' +declare module 'leaflet.heat' declare module 'vuetify' declare module 'vuetify/lib/components' declare module 'vuetify/lib/directives' From 537154d5d1409ddd13d9f581a6da3edfc19b359e Mon Sep 17 00:00:00 2001 From: Arturo Manzoli Date: Tue, 26 May 2026 09:49:53 -0300 Subject: [PATCH 4/5] base-station: bridge OpenCellID through the standalone Electron build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenCellID ajax endpoint is keyless but blocked by browser CORS, so the renderer can't fetch coverage data directly even on the standalone build — operators would otherwise be forced to obtain and paste in an API key just to see anything. - Add an Electron network service that proxies the OpenCellID call from the main process, with a per-bbox cache (keyed by both the bbox and a fingerprint of the API key), request queue and rate-limit detection. - Expose it through the preload bridge so the renderer treats it like any other Electron API via `cosmos.ts`. - Hoist the OpenCellID DTOs into `src/types/baseStation.ts` so main and renderer share the same shape. --- README.md | 1 + src/electron/main.ts | 2 + src/electron/preload.ts | 3 + src/electron/services/openCellId.ts | 202 ++++++++++++++++++++++++++++ src/libs/cosmos.ts | 9 ++ src/types/baseStation.ts | 33 +++++ 6 files changed, 250 insertions(+) create mode 100644 src/electron/services/openCellId.ts diff --git a/README.md b/README.md index cab44bdb3c..33c3b6d58d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ Below is a table summarizing the current status, but in general, you can expect | **Video** | Needs to be downloaded and merged into a working video using the Desktop app | ✅ Final MP4 file saved directly to your folders | | **Snapshots** | Needs to be downloaded | ✅ Saved directly to your folders | | **Vehicle Discovery** | ❌ Not available | ✅ Auto-scan for vehicles in the network| +| **Mobile Coverage (OpenCellID)** | Requires a personal OpenCellID API key (browser CORS blocks the key-free endpoint) | ✅ Fetched through the built-in bridge (no browser CORS), but still needs a personal OpenCellID API key. Use the OpenStreetMap provider for key-free coverage | | **Updates** | Manual updates required | ✅ Auto-updates / update notifications | | **System Monitoring** | Memory usage only | ✅ CPU and Memory tracking | | **Workspace Capture** | ❌ Not available | ✅ Full interface screenshots | diff --git a/src/electron/main.ts b/src/electron/main.ts index e2714c852d..6eaeaa608a 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -9,6 +9,7 @@ import { setupHardwareTelemetryService } from './services/hardware-telemetry' import { setupJoystickMonitoring } from './services/joystick' import { linkService } from './services/link' import { setupNetworkService } from './services/network' +import { setupOpenCellIdService } from './services/openCellId' import { setupOsmRefererService } from './services/osm-referer' import { setupResourceMonitoringService } from './services/resource-monitoring' import { setupFilesystemStorage } from './services/storage' @@ -97,6 +98,7 @@ protocol.registerSchemesAsPrivileged([ setupFilesystemStorage() setupNetworkService() +setupOpenCellIdService() setupResourceMonitoringService() setupSystemInfoService() setupHardwareTelemetryService() diff --git a/src/electron/preload.ts b/src/electron/preload.ts index cb150e769a..f1cfbcafd6 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' +import type { OpenCellIdBboxRequest } from '@/types/baseStation' import type { ElectronSDLJoystickControllerStateEventData } from '@/types/joystick' import type { FileDialogOptions, FileStats } from '@/types/storage' @@ -8,6 +9,8 @@ contextBridge.exposeInMainWorld('electronAPI', { checkTcpPortOpen: (host: string, port: number, timeoutMs: number) => ipcRenderer.invoke('check-tcp-port-open', host, port, timeoutMs), abortTcpPortProbes: () => ipcRenderer.invoke('abort-tcp-port-probes'), + fetchNearbyOpenCellIdCells: (bbox: OpenCellIdBboxRequest) => + ipcRenderer.invoke('fetch-nearby-open-cell-id-cells', bbox), getResourceUsage: () => ipcRenderer.invoke('get-resource-usage'), onUpdateAvailable: (callback: (info: any) => void) => ipcRenderer.on('update-available', (_event, info) => callback(info)), diff --git a/src/electron/services/openCellId.ts b/src/electron/services/openCellId.ts new file mode 100644 index 0000000000..de49fbc97d --- /dev/null +++ b/src/electron/services/openCellId.ts @@ -0,0 +1,202 @@ +import { createHash } from 'crypto' +import { ipcMain } from 'electron' + +import type { NearbyOpenCellIdCell, OpenCellIdBboxRequest } from '../../types/baseStation' + +/* eslint-disable jsdoc/require-jsdoc -- Internal OpenCellID DTOs; field meanings follow upstream API docs. */ +type OpenCellIdOfficialResponse = { + cells?: OpenCellIdOfficialCell[] + error?: string + code?: number +} + +type OpenCellIdOfficialCell = { + lat: number + lon: number + range?: number + radio?: string + mcc?: number + mnc?: number + lac?: number + cellid?: number + samples?: number + averageSignalStrength?: number +} + +type CachedOpenCellIdBboxEntry = { + cachedAtMs: number + cells: NearbyOpenCellIdCell[] +} +/* eslint-enable jsdoc/require-jsdoc */ + +const OPEN_CELL_ID_MIN_REQUEST_INTERVAL_MS = 350 +const OPEN_CELL_ID_CACHE_TTL_MS = 10 * 60 * 1000 +const OPEN_CELL_ID_REQUEST_TIMEOUT_MS = 15 * 1000 +const OPEN_CELL_ID_RATE_LIMIT_COOLDOWN_MS = 2 * 60 * 1000 +const OPEN_CELL_ID_TOO_MANY_REQUESTS_BACKOFF_MS = [2000, 5000, 10000] +const OPEN_CELL_ID_BBOX_CACHE_MAX_ENTRIES = 200 +const openCellIdBboxCache = new Map() +const openCellIdInflight = new Map>() +let openCellIdQueue: Promise = Promise.resolve() +let lastOpenCellIdRequestMs = 0 +let openCellIdRateLimitUntilMs = 0 + +const trimOpenCellIdCache = (): void => { + while (openCellIdBboxCache.size > OPEN_CELL_ID_BBOX_CACHE_MAX_ENTRIES) { + const oldestKey = openCellIdBboxCache.keys().next().value + if (oldestKey === undefined) break + openCellIdBboxCache.delete(oldestKey) + } +} + +const validateBbox = (bbox: OpenCellIdBboxRequest): void => { + for (const value of [bbox.west, bbox.south, bbox.east, bbox.north]) { + if (!Number.isFinite(value)) throw new Error('OpenCellID: bbox must contain finite numbers.') + } + if (bbox.south < -90 || bbox.south > 90 || bbox.north < -90 || bbox.north > 90) { + throw new Error('OpenCellID: latitude out of [-90, 90] range.') + } + if (bbox.west < -180 || bbox.west > 180 || bbox.east < -180 || bbox.east > 180) { + throw new Error('OpenCellID: longitude out of [-180, 180] range.') + } + if (bbox.south >= bbox.north) throw new Error('OpenCellID: bbox south must be < north.') + if (bbox.west >= bbox.east) throw new Error('OpenCellID: bbox west must be < east.') +} + +const fetchWithTimeout = async (url: string, timeoutMs: number): Promise => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +const sleep = async (delayMs: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +// Switching API keys must invalidate cached responses fetched with the previous key, so the +// cache key folds in a short fingerprint of the trimmed key (or `none` when missing). +const openCellIdApiKeyFingerprint = (apiKey: string | undefined): string => { + const trimmed = apiKey?.trim() ?? '' + if (!trimmed) return 'none' + return createHash('sha256').update(trimmed).digest('hex').slice(0, 12) +} + +const openCellIdBboxKey = (bbox: OpenCellIdBboxRequest): string => + `${openCellIdApiKeyFingerprint(bbox.apiKey)}:` + + [bbox.west, bbox.south, bbox.east, bbox.north].map((value) => value.toFixed(6)).join(':') + +const isTooManyRequestsError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false + return /\b429\b/.test(error.message) || /too many requests/i.test(error.message) +} + +const fetchOpenCellIdCellsWithApiKey = async ( + bbox: OpenCellIdBboxRequest, + apiKey: string +): Promise => { + const url = new URL('https://opencellid.org/cell/getInArea') + url.searchParams.set('key', apiKey) + url.searchParams.set('BBOX', `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`) + url.searchParams.set('format', 'json') + url.searchParams.set('limit', '50') + + const response = await fetchWithTimeout(url.toString(), OPEN_CELL_ID_REQUEST_TIMEOUT_MS) + if (!response.ok) throw new Error(`OpenCellID HTTP ${response.status}`) + + const data = (await response.json()) as OpenCellIdOfficialResponse + if (data.error && data.code !== 1) throw new Error(`OpenCellID: ${data.error}`) + return (data.cells ?? []).map((cell) => ({ + lat: cell.lat, + lon: cell.lon, + range: cell.range, + radio: cell.radio, + mcc: cell.mcc, + mnc: cell.mnc, + lac: cell.lac, + cellId: cell.cellid, + samples: cell.samples, + averageSignalStrength: cell.averageSignalStrength, + })) +} + +const fetchOpenCellIdCellsQueued = async (bbox: OpenCellIdBboxRequest): Promise => { + validateBbox(bbox) + const apiKey = bbox.apiKey?.trim() + if (!apiKey) throw new Error('OpenCellID: missing API key.') + + const key = openCellIdBboxKey(bbox) + const cached = openCellIdBboxCache.get(key) + if (cached && Date.now() - cached.cachedAtMs < OPEN_CELL_ID_CACHE_TTL_MS) { + // Refresh LRU order so hot entries survive the size-based eviction below. + openCellIdBboxCache.delete(key) + openCellIdBboxCache.set(key, cached) + return cached.cells + } + + const inflight = openCellIdInflight.get(key) + if (inflight) return inflight + if (Date.now() < openCellIdRateLimitUntilMs) { + throw new Error('OpenCellID: Too many requests. Cooling down before the next retry.') + } + + const request = new Promise((resolve, reject) => { + openCellIdQueue = openCellIdQueue + .catch(() => undefined) + .then(async () => { + try { + const waitMs = Math.max(0, OPEN_CELL_ID_MIN_REQUEST_INTERVAL_MS - (Date.now() - lastOpenCellIdRequestMs)) + if (waitMs > 0) await sleep(waitMs) + + let lastError: unknown = undefined + for (let attempt = 0; attempt <= OPEN_CELL_ID_TOO_MANY_REQUESTS_BACKOFF_MS.length; attempt++) { + try { + const cells = await fetchOpenCellIdCellsWithApiKey(bbox, apiKey) + lastOpenCellIdRequestMs = Date.now() + openCellIdBboxCache.set(key, { cachedAtMs: Date.now(), cells }) + trimOpenCellIdCache() + resolve(cells) + return + } catch (error) { + lastError = error + if (!isTooManyRequestsError(error) || attempt === OPEN_CELL_ID_TOO_MANY_REQUESTS_BACKOFF_MS.length) { + if (isTooManyRequestsError(error)) { + openCellIdRateLimitUntilMs = Date.now() + OPEN_CELL_ID_RATE_LIMIT_COOLDOWN_MS + } + reject(error) + return + } + await sleep(OPEN_CELL_ID_TOO_MANY_REQUESTS_BACKOFF_MS[attempt]) + } + } + reject(lastError) + } catch (error) { + reject(error) + } + }) + }).finally(() => { + openCellIdInflight.delete(key) + }) + + openCellIdInflight.set(key, request) + return request +} + +const fetchNearbyOpenCellIdCells = async ( + _event: Electron.IpcMainInvokeEvent, + bbox: OpenCellIdBboxRequest +): Promise => { + return await fetchOpenCellIdCellsQueued(bbox) +} + +/** + * Setup the OpenCellID bridge service. Exposes a renderer-callable IPC handler + * that proxies bbox queries to opencellid.org with caching, rate-limiting and + * 429 backoff so the renderer never sees the raw upstream API. + */ +export const setupOpenCellIdService = (): void => { + ipcMain.handle('fetch-nearby-open-cell-id-cells', fetchNearbyOpenCellIdCells) +} diff --git a/src/libs/cosmos.ts b/src/libs/cosmos.ts index cc6a1b9638..06b87b1af0 100644 --- a/src/libs/cosmos.ts +++ b/src/libs/cosmos.ts @@ -1,5 +1,6 @@ import { isBrowser } from 'browser-or-node' +import type { NearbyOpenCellIdCell, OpenCellIdBboxRequest } from '@/types/baseStation' import { type ElectronLog } from '@/types/electron-general' import { ElectronStorageDB } from '@/types/general' import type { ElectronSDLJoystickControllerStateEventData } from '@/types/joystick' @@ -206,6 +207,14 @@ declare global { * @returns Promise containing subnet information */ getInfoOnSubnets: () => Promise + // The IPC handler does not currently accept an `AbortSignal`, so the renderer cannot + // cancel an in-flight call — wait for it to settle and discard the result if needed. + /** + * Fetch nearby OpenCellID cells from the main process, bypassing browser CORS limits. + * @param {OpenCellIdBboxRequest} bbox Geographic bounding box plus an optional API key. + * @returns {Promise} Cells inside `bbox`, possibly empty. + */ + fetchNearbyOpenCellIdCells: (bbox: OpenCellIdBboxRequest) => Promise /** * Fast TCP port probe used as a pre-filter during vehicle discovery * @param host IPv4 address to probe diff --git a/src/types/baseStation.ts b/src/types/baseStation.ts index 06e1c3a27e..be656fff06 100644 --- a/src/types/baseStation.ts +++ b/src/types/baseStation.ts @@ -237,3 +237,36 @@ export const DEFAULT_BASE_STATION_CONFIG: BaseStationConfig = { coverageColor: '#3B82F6', coverageOpacity: 1, } + +/** + * Bounding box payload accepted by the OpenCellID `getInArea` endpoint, plus the API key the + * caller wants to use (omitted to fall back to the anonymous public endpoint). + */ +/* eslint-disable jsdoc/require-jsdoc -- Field names mirror the OpenCellID HTTP contract. */ +export type OpenCellIdBboxRequest = { + west: number + south: number + east: number + north: number + apiKey?: string +} +/* eslint-enable jsdoc/require-jsdoc */ + +/** + * Single OpenCellID cell record after normalization. `range` is the published reach in meters + * and is preserved so callers can render proportionally sized rings or weight heatmaps. + */ +/* eslint-disable jsdoc/require-jsdoc -- Field names mirror the OpenCellID HTTP contract. */ +export type NearbyOpenCellIdCell = { + lat: number + lon: number + range?: number + radio?: string + mcc?: number + mnc?: number + lac?: number + cellId?: number + samples?: number + averageSignalStrength?: number +} +/* eslint-enable jsdoc/require-jsdoc */ From 2e8a83a4aae7e547a3972bd43a176b4ee5df8dfc Mon Sep 17 00:00:00 2001 From: Arturo Manzoli Date: Tue, 26 May 2026 09:54:43 -0300 Subject: [PATCH 5/5] base-station: add heatmap and coverage-rings display modes The first mobile-data overlay drew each site as a flat dot, which hides both the cell's range and the local site density, and every panel switch refetched the same bbox from the rate-limited OpenCellID and Overpass endpoints. - Render two new display modes: `Heatmap` (heat layer keyed on cell density) and `CoverageRings` (stacked ring polygons sized by the per-tower range estimate). - Cache OpenCellID and Overpass responses per bbox in the store, with LRU trimming to `MOBILE_COVERAGE_CACHE_MAX_ENTRIES`. - Add a drag-to-fetch target tool for areas outside the default auto-fetch radius, plus ring rim labels and a heatmap intensity slider in the config panel. - Default-merge the persisted `mobileCoverageCache` on load so a schema bump doesn't crash callers that read its fields. - Surface an info snackbar on Lite when no API key is set instead of failing silently. --- src/components/BaseStationConfigPanel.vue | 425 ++++- .../baseStation/baseStationOverlay.css | 1 - src/composables/baseStation/useBaseStation.ts | 60 +- .../baseStation/useBaseStationOverlay.ts | 1506 +++++++++++++++-- src/types/baseStation.ts | 94 +- src/types/shims.d.ts | 1 - 6 files changed, 1897 insertions(+), 190 deletions(-) diff --git a/src/components/BaseStationConfigPanel.vue b/src/components/BaseStationConfigPanel.vue index 681ee5723c..ec4677a813 100644 --- a/src/components/BaseStationConfigPanel.vue +++ b/src/components/BaseStationConfigPanel.vue @@ -26,6 +26,10 @@ @@ -78,11 +69,21 @@ + +