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/components/BaseStationConfigPanel.vue b/src/components/BaseStationConfigPanel.vue new file mode 100644 index 0000000000..ec4677a813 --- /dev/null +++ b/src/components/BaseStationConfigPanel.vue @@ -0,0 +1,1249 @@ + + + + + + + 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..4e8a3b9059 100644 --- a/src/components/widgets/Map.vue +++ b/src/components/widgets/Map.vue @@ -232,6 +232,8 @@

+ + vehiclePosition.value) targetFollower.setTrackableTarget(WhoToFollow.HOME, () => home.value) +useBaseStationOverlay(map, mapReady) + // Calculate live vehicle position const vehiclePosition = computed(() => vehicleStore.coordinates.latitude @@ -1464,6 +1473,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 +1505,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 +1519,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 +1745,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..5da6bd0bd3 --- /dev/null +++ b/src/composables/baseStation/baseStationOverlay.css @@ -0,0 +1,53 @@ +/* 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%; + 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; +} + +.base-station-bearing-handle { + background: none; + border: none; + cursor: grab; +} + +.base-station-bearing-handle-dot { + width: 14px; + height: 14px; + border-radius: 50%; + background-color: #fff; + border: 2px solid #008bff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} diff --git a/src/composables/baseStation/useBaseStation.ts b/src/composables/baseStation/useBaseStation.ts new file mode 100644 index 0000000000..3e4af7a152 --- /dev/null +++ b/src/composables/baseStation/useBaseStation.ts @@ -0,0 +1,244 @@ +import { computed, reactive, ref, watch } from 'vue' + +import type { DialogOptions, DialogResult } from '@/composables/interactionDialog' +import { useBlueOsStorage } from '@/composables/settingsSyncer' +import { openSnackbar } from '@/composables/snackbar' +import { useAppInterfaceStore } from '@/stores/appInterface' +import { + type BaseStationConfig, + type MobileCoverageCache, + ANTENNA_FACTORY_DEFAULTS, + AntennaType, + BaseStationCommsType, + DEFAULT_BASE_STATION_CONFIG, + DEFAULT_MOBILE_COVERAGE_CACHE, + TopSideComputerType, +} from '@/types/baseStation' +import type { DialogActions } from '@/types/general' +import type { WaypointCoordinates } from '@/types/mission' + +const normalizeBearing = (bearing: number): number => ((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) + const mobileCoverageCache = useBlueOsStorage( + 'cockpit-base-station-mobile-coverage-cache', + DEFAULT_MOBILE_COVERAGE_CACHE + ) + + // Merge defaults so newly-added fields are populated for existing users. + config.value = { + ...DEFAULT_BASE_STATION_CONFIG, + ...config.value, + antenna: { ...DEFAULT_BASE_STATION_CONFIG.antenna, ...(config.value.antenna ?? {}) }, + mobileCoverage: { + ...DEFAULT_BASE_STATION_CONFIG.mobileCoverage, + ...(config.value.mobileCoverage ?? {}), + }, + } + mobileCoverageCache.value = { + ...DEFAULT_MOBILE_COVERAGE_CACHE, + ...mobileCoverageCache.value, + openCellId: mobileCoverageCache.value.openCellId ?? [], + osmOverpass: mobileCoverageCache.value.osmOverpass ?? [], + } + + // 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 availableOpenCellIdOperators = ref([]) + const mobileCoverageLoading = ref(false) + const mobileCoverageReloadToken = ref(0) + const mobileCoverageVisibleDataResetToken = ref(0) + const mobileCoverageTargetToolActive = ref(false) + const openCellIdApiKeyStatus = ref<'unknown' | 'valid' | 'invalid'>('unknown') + + const configPanelOpen = ref(false) + + const interfaceStore = useAppInterfaceStore() + watch(configPanelOpen, (isOpen) => { + interfaceStore.configPanelVisible = isOpen + }) + + 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 requestMobileCoverageReload = (): void => { + mobileCoverageReloadToken.value += 1 + } + + const requestVisibleMobileCoverageDataReset = (): void => { + mobileCoverageVisibleDataResetToken.value += 1 + } + + 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 + } + + const remove = (): void => { + // Keep the OpenCellID API key around as a user-level credential — having to retype it + // every time the base station is removed/recreated would be annoying and error-prone. + const preservedApiKey = config.value.mobileCoverage.openCellIdApiKey + config.value = { + ...DEFAULT_BASE_STATION_CONFIG, + mobileCoverage: { ...DEFAULT_BASE_STATION_CONFIG.mobileCoverage, openCellIdApiKey: preservedApiKey }, + } + configPanelOpen.value = false + 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) + + watch( + () => config.value.topSideComputerType, + (topSideType) => { + if (topSideType !== TopSideComputerType.Portable) config.value.trackByGps = false + }, + { immediate: true } + ) + + // Provider/key changes invalidate any previously-determined validity; the next fetch resets it. + watch( + () => [config.value.mobileCoverage.provider, config.value.mobileCoverage.openCellIdApiKey] as const, + () => { + openCellIdApiKeyStatus.value = 'unknown' + } + ) + + return reactive({ + config, + mobileCoverageCache, + configPanelOpen, + contextPopupOpen, + contextPopupPosition, + availableOsmOperators, + availableOpenCellIdOperators, + mobileCoverageLoading, + mobileCoverageReloadToken, + mobileCoverageVisibleDataResetToken, + mobileCoverageTargetToolActive, + openCellIdApiKeyStatus, + showCoverage, + setPosition, + setBearing, + setAntennaType, + resetAntennaToDefaults, + openContextPopup, + closeContextPopup, + requestMobileCoverageReload, + requestVisibleMobileCoverageDataReset, + 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..74c6cb3403 --- /dev/null +++ b/src/composables/baseStation/useBaseStationOverlay.ts @@ -0,0 +1,1828 @@ +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 { openSnackbar } from '@/composables/snackbar' +import { isElectron } from '@/libs/utils' +import { + type BaseStationConfig, + type CachedMobileCoverageEntry, + type CachedOpenCellIdSite, + type CachedOverpassTower, + type CoverageBbox, + AntennaType, + BaseStationCommsType, + effectiveAntennaRangeMeters, + MOBILE_COVERAGE_FETCH_DROP_MIME, + MobileCoverageDisplayMode, + MobileCoverageProvider, +} 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 notifyOpenCellIdKeyRequired = (): void => { + openSnackbar({ + variant: 'info', + message: + 'OpenCellID requires a personal API key. Add one in the base-station config, or switch to OpenStreetMap coverage to load data without a key.', + duration: 5000, + }) +} + +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 -- 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 +const OPENCELLID_COVERAGE_HALF_SIDE_KM = 25 +// Keyed browser endpoint caps at ~4 km²; standalone's no-key ajax endpoint rejects around 25 km², +// so we use smaller tiles on Lite and larger ones on standalone. +const OPENCELLID_KEYED_TILE_HALF_SIDE_KM = 0.95 +const OPENCELLID_KEYED_FOREGROUND_TILE_COUNT = 9 +const OPENCELLID_LITE_TILE_HALF_SIDE_KM = 1 +const OPENCELLID_STANDALONE_TILE_HALF_SIDE_KM = 2 +const OPENCELLID_STANDALONE_FOREGROUND_TILE_COUNT = 1 +// Keep the persisted bbox cache small; users rarely need more than the most recent few areas. +const MOBILE_COVERAGE_CACHE_MAX_ENTRIES = 8 +type OpenCellIdSite = CachedOpenCellIdSite +type OverpassTower = CachedOverpassTower + +const isOpenCellIdInvalidApiKeyError = (message: string): boolean => + /invalid.*key|api key.*invalid|missing.*key|key required|unauthorized|forbidden/i.test(message) + +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, +}) + +const kmToLatDegrees = (km: number): number => km / 111.32 + +const kmToLngDegrees = (km: number, lat: number): number => { + const cosLat = Math.max(0.2, Math.cos((lat * Math.PI) / 180)) + return km / (111.32 * cosLat) +} + +const openCellIdCoverageBboxAround = (lat: number, lng: number): CoverageBbox => ({ + south: lat - kmToLatDegrees(OPENCELLID_COVERAGE_HALF_SIDE_KM), + west: lng - kmToLngDegrees(OPENCELLID_COVERAGE_HALF_SIDE_KM, lat), + north: lat + kmToLatDegrees(OPENCELLID_COVERAGE_HALF_SIDE_KM), + east: lng + kmToLngDegrees(OPENCELLID_COVERAGE_HALF_SIDE_KM, lat), +}) + +const bboxContains = (bbox: CoverageBbox, position: WaypointCoordinates): boolean => { + const [lat, lng] = position + return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east +} + +const bboxIntersects = (left: CoverageBbox, right: CoverageBbox): boolean => + left.west <= right.east && left.east >= right.west && left.south <= right.north && left.north >= right.south + +const bboxEquals = (left: CoverageBbox, right: CoverageBbox): boolean => + left.south === right.south && left.west === right.west && left.north === right.north && left.east === right.east + +const leafletBoundsToCoverageBbox = (bounds: L.LatLngBounds): CoverageBbox => ({ + south: bounds.getSouth(), + west: bounds.getWest(), + north: bounds.getNorth(), + east: bounds.getEast(), +}) + +const trimCacheEntries = (entries: CachedMobileCoverageEntry[]): CachedMobileCoverageEntry[] => { + return entries.sort((a, b) => b.fetchedAtMs - a.fetchedAtMs).slice(0, MOBILE_COVERAGE_CACHE_MAX_ENTRIES) +} + +const tiledCoverageBboxes = (area: CoverageBbox, centerLat: number, tileHalfSideKm: number): CoverageBbox[] => { + const tileLatSize = kmToLatDegrees(tileHalfSideKm * 2) + const tileLngSize = kmToLngDegrees(tileHalfSideKm * 2, centerLat) + const latCount = Math.ceil((area.north - area.south) / tileLatSize) + const lngCount = Math.ceil((area.east - area.west) / tileLngSize) + const tiles: CoverageBbox[] = [] + + for (let latIndex = 0; latIndex < latCount; latIndex++) { + const south = area.south + latIndex * tileLatSize + const north = Math.min(area.north, south + tileLatSize) + for (let lngIndex = 0; lngIndex < lngCount; lngIndex++) { + const west = area.west + lngIndex * tileLngSize + const east = Math.min(area.east, west + tileLngSize) + tiles.push({ south, west, north, east }) + } + } + + return tiles +} + +const bboxCenter = (bbox: CoverageBbox): WaypointCoordinates => [ + (bbox.south + bbox.north) / 2, + (bbox.west + bbox.east) / 2, +] + +const sortCoverageBboxesByDistance = (center: WaypointCoordinates, bboxes: CoverageBbox[]): CoverageBbox[] => + [...bboxes].sort((left, right) => { + const [leftLat, leftLng] = bboxCenter(left) + const [rightLat, rightLng] = bboxCenter(right) + const leftDistance = turf.distance(turf.point([center[1], center[0]]), turf.point([leftLng, leftLat])) + const rightDistance = turf.distance(turf.point([center[1], center[0]]), turf.point([rightLng, rightLat])) + return leftDistance - rightDistance + }) + +const unionCoverageBboxes = (bboxes: CoverageBbox[]): CoverageBbox => ({ + south: Math.min(...bboxes.map((bbox) => bbox.south)), + west: Math.min(...bboxes.map((bbox) => bbox.west)), + north: Math.max(...bboxes.map((bbox) => bbox.north)), + east: Math.max(...bboxes.map((bbox) => bbox.east)), +}) + +const mapWithConcurrency = async ( + items: T[], + concurrency: number, + mapper: (item: T) => Promise +): Promise[]> => { + const settled: PromiseSettledResult[] = new Array(items.length) + let nextIndex = 0 + + const worker = async (): Promise => { + while (nextIndex < items.length) { + const currentIndex = nextIndex++ + try { + settled[currentIndex] = { + status: 'fulfilled', + value: await mapper(items[currentIndex]), + } + } catch (error) { + settled[currentIndex] = { + status: 'rejected', + reason: error, + } + } + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())) + return settled +} + +type OpenCellIdResponse = { + cells?: Array<{ + lat: number + lon: number + range?: number + radio?: string + mcc?: number + mnc?: number + lac?: number + cellid?: number + samples?: number + averageSignalStrength?: 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&limit=50` + 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) => ({ + lat: c.lat, + lon: c.lon, + rangeMeters: c.range ?? 1000, + radio: c.radio, + mcc: c.mcc, + mnc: c.mnc, + lac: c.lac, + cellId: c.cellid, + samples: c.samples, + averageSignalStrength: c.averageSignalStrength, + })) +} + +const fetchOpenCellIdTileInElectron = async (bbox: CoverageBbox, apiKey: string): Promise => { + const cells = await window.electronAPI?.fetchNearbyOpenCellIdCells({ + west: bbox.west, + south: bbox.south, + east: bbox.east, + north: bbox.north, + apiKey: apiKey || undefined, + }) + if (!cells) throw new Error('OpenCellID standalone bridge unavailable') + return cells.map((c) => ({ + lat: c.lat, + lon: c.lon, + rangeMeters: c.range ?? 1000, + radio: c.radio, + mcc: c.mcc, + mnc: c.mnc, + lac: c.lac, + cellId: c.cellId, + samples: c.samples, + averageSignalStrength: c.averageSignalStrength, + })) +} + +const fetchOpenCellIdSites = async ( + center: WaypointCoordinates, + apiKey: string, + signal: AbortSignal +): Promise<{ sites: OpenCellIdSite[]; fetchedBbox: CoverageBbox }> => { + const [lat, lng] = center + const coverageArea = openCellIdCoverageBboxAround(lat, lng) + const usesApiKey = apiKey.trim().length > 0 + const tileHalfSideKm = usesApiKey + ? OPENCELLID_KEYED_TILE_HALF_SIDE_KM + : isElectron() + ? OPENCELLID_STANDALONE_TILE_HALF_SIDE_KM + : OPENCELLID_LITE_TILE_HALF_SIDE_KM + const tiles = tiledCoverageBboxes(coverageArea, lat, tileHalfSideKm) + const selectedTiles = usesApiKey + ? sortCoverageBboxesByDistance(center, tiles).slice(0, OPENCELLID_KEYED_FOREGROUND_TILE_COUNT) + : isElectron() + ? sortCoverageBboxesByDistance(center, tiles).slice(0, OPENCELLID_STANDALONE_FOREGROUND_TILE_COUNT) + : tiles + const tileFetcher = isElectron() + ? (bbox: CoverageBbox) => fetchOpenCellIdTileInElectron(bbox, apiKey) + : (bbox: CoverageBbox) => fetchOpenCellIdTile(bbox, apiKey, signal) + const settled = await mapWithConcurrency(selectedTiles, usesApiKey ? 4 : isElectron() ? 2 : 4, tileFetcher) + 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 + } + const uniqueSites = new Map() + for (const site of fulfilled.flatMap((r) => r.value)) { + const key = `${site.lat.toFixed(6)}:${site.lon.toFixed(6)}:${Math.round(site.rangeMeters)}` + if (!uniqueSites.has(key)) uniqueSites.set(key, site) + } + return { + sites: [...uniqueSites.values()], + fetchedBbox: unionCoverageBboxes(selectedTiles), + } +} + +type OverpassResponse = { + elements?: Array<{ id?: number; lat?: number; lon?: number; tags?: Record }> +} + +type OsmCoverageLabelSpec = { + id: string + center: WaypointCoordinates + rangeMeters: number + bearing: number | null + beamwidth: number + labelParts: string[] + color: string +} + +const OSM_DEFAULT_RANGE_METERS = 1800 +const OSM_DEFAULT_DIRECTIONAL_BEAMWIDTH = 90 +const OSM_COVERAGE_FILL_OPACITY = 0.12 +const OSM_COVERAGE_STROKE_OPACITY = 0.75 +const OPENCELLID_RING_FILL_OPACITY = 0.08 +const OSM_LABEL_FONT_SIZE_PX = 10 +const OSM_LABEL_CHAR_WIDTH_PX = 5.7 +const OSM_LABEL_RIM_INSET = 0.92 +const OSM_TOP_ARC_START_DEG = 300 +const OSM_TOP_ARC_END_DEG = 60 +const OSM_OPERATOR_COLORS = ['#38BDF8', '#22C55E', '#A855F7', '#F59E0B', '#EF4444', '#14B8A6'] + +const splitTagList = (value?: string): string[] => + value + ?.split(/[;,]/) + .map((item) => item.trim()) + .filter(Boolean) ?? [] + +const parseTagNumber = (tags: Record, ...keys: string[]): number | null => { + for (const key of keys) { + const value = tags[key] + if (!value) continue + const parsed = parseFloat(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + +const pickTagValue = (tags: Record, ...keys: string[]): string | null => { + for (const key of keys) { + const value = tags[key]?.trim() + if (value) return value + } + return null +} + +const formatHeightLabel = (height: string): string => (/^[\d.]+$/.test(height) ? `${height}m` : height) + +const normalizeAngle = (angle: number): number => ((angle % 360) + 360) % 360 + +const overpassTechnologies = (tags: Record): string[] => { + const technologies = splitTagList(tags['technology:mobile_phone']).map((tech) => tech.toLowerCase()) + const legacyCommunication = splitTagList(tags['communication:mobile_phone']) + .map((tech) => tech.toLowerCase()) + .filter((tech) => tech !== 'yes') + return [...new Set([...technologies, ...legacyCommunication])] +} + +const overpassTechnologyGenerations = (tags: Record): string[] => { + const technologies = overpassTechnologies(tags) + const generations = technologies.flatMap((tech) => { + if (tech.includes('nr') || tech.includes('5g')) return ['5G'] + if (tech.includes('lte')) return ['4G'] + if (tech.includes('umts') || tech.includes('wcdma') || tech.includes('hspa') || tech.includes('hsupa')) + return ['3G'] + if (tech.includes('gsm') || tech.includes('edge') || tech.includes('gprs')) return ['2G'] + return [] + }) + return [...new Set(generations)] +} + +const overpassTechnologyLabel = (tags: Record): string | null => { + const generations = overpassTechnologyGenerations(tags) + if (generations.length > 0) return generations.join('/') + const technologies = overpassTechnologies(tags) + if (technologies.length === 0) return null + return technologies.map((tech) => tech.toUpperCase()).join('/') +} + +const overpassRangeMeters = (tags: Record): number => { + const technologies = overpassTechnologies(tags) + if (technologies.length === 0) return OSM_DEFAULT_RANGE_METERS + return Math.max( + ...technologies.map((tech) => { + if (tech.includes('nr') || tech.includes('5g')) return 1200 + if (tech.includes('lte')) return 1800 + if (tech.includes('umts')) return 2200 + if (tech.includes('gsm')) return 2800 + return OSM_DEFAULT_RANGE_METERS + }) + ) +} + +const overpassBearing = (tags: Record): number | null => { + const parsed = parseTagNumber(tags, 'communications_transponder:bearing', 'antenna:direction', 'direction', 'bearing') + return parsed === null ? null : normalizeAngle(parsed) +} + +const overpassBeamwidth = (tags: Record, bearing: number | null): number => { + const parsed = parseTagNumber(tags, 'beamwidth', 'antenna:beamwidth') + if (parsed !== null && parsed > 0 && parsed <= 360) return parsed + return bearing === null ? 360 : OSM_DEFAULT_DIRECTIONAL_BEAMWIDTH +} + +const overpassLabelParts = (tower: OverpassTower): string[] => { + const parts = [tower.operator ?? 'Unknown operator'] + const technologyLabel = overpassTechnologyLabel(tower.tags) + const manMade = pickTagValue(tower.tags, 'man_made') + const height = pickTagValue(tower.tags, 'height') + + if (technologyLabel) parts.push(technologyLabel) + if (manMade) parts.push(manMade) + if (height) parts.push(formatHeightLabel(height)) + return parts +} + +const fitLabelToArc = (parts: string[], maxWidthPx: number): string => { + for (let count = parts.length; count > 0; count--) { + const candidate = parts.slice(0, count).join(' - ') + if (candidate.length * OSM_LABEL_CHAR_WIDTH_PX <= maxWidthPx) return candidate + } + const fallback = parts[0] + const maxChars = Math.max(8, Math.floor(maxWidthPx / OSM_LABEL_CHAR_WIDTH_PX) - 1) + return fallback.length > maxChars ? `${fallback.slice(0, maxChars)}…` : fallback +} + +const openCellIdOperatorLabel = (site: OpenCellIdSite): string | null => { + if (site.mcc === undefined || site.mnc === undefined) return null + return `MCC ${site.mcc} / MNC ${site.mnc}` +} + +const openCellIdLabelParts = (site: OpenCellIdSite): string[] => { + const parts: string[] = [] + const operator = openCellIdOperatorLabel(site) + if (operator) parts.push(operator) + if (site.radio) parts.push(site.radio.toUpperCase()) + parts.push(`${Math.round(site.rangeMeters)}m`) + if (site.cellId !== undefined) parts.push(`CID ${site.cellId}`) + return parts +} + +const filterOpenCellIdSites = (sites: OpenCellIdSite[], selectedOperator: string): OpenCellIdSite[] => { + if (!selectedOperator) return sites + return sites.filter((site) => openCellIdOperatorLabel(site) === selectedOperator) +} + +const operatorColor = (operator: string | null): string => { + if (!operator) return OSM_OPERATOR_COLORS[0] + const hash = [...operator].reduce((acc, char) => acc + char.charCodeAt(0), 0) + return OSM_OPERATOR_COLORS[hash % OSM_OPERATOR_COLORS.length] +} + +const fetchOverpassTowers = async (bbox: CoverageBbox, signal: AbortSignal): Promise => { + const region = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})` + const query = + `[out:json][timeout:25];` + + // Limit the overlay to mobile-phone infrastructure so the map reflects cellular coverage + // rather than every generic communications site in the area. + `(node["communication:mobile_phone"]${region};` + + `node["technology:mobile_phone"]${region};);` + + `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 + const seenIds = new Set() + return (data.elements ?? []) + .filter( + (e): e is { id: number; lat: number; lon: number; tags?: Record } => + typeof e.id === 'number' && typeof e.lat === 'number' && typeof e.lon === 'number' + ) + .filter((e) => { + if (seenIds.has(e.id)) return false + seenIds.add(e.id) + return true + }) + .map((e) => ({ + id: e.id, + lat: e.lat, + lon: e.lon, + operator: e.tags?.operator?.trim() || null, + tags: e.tags ?? {}, + })) +} + +/* eslint-enable jsdoc/require-jsdoc */ + +// Slider 0% → blob radius is 5% of the cell range, slider 100% → full range. Linear blend +// between the two so the slider has visible effect at every zoom level. +const OPENCELLID_HEATMAP_MIN_RADIUS_FRACTION = 0.05 +// Per-cell peak alpha contributed at the gradient center. Two cells overlapping at full alpha +// reach 1.0 thanks to the `lighter` compositing — this is what surfaces the warm hotspot tail +// of the gradient when several towers cover the same patch. +const OPENCELLID_HEATMAP_PEAK_ALPHA = 0.55 +// Cool-to-warm gradient stops mapped from per-pixel density (0..1). A single cell tops out +// around mid-gradient (cyan/green); two cells overlapping bleed into yellow; three or more +// reach the red hotspot range. +const OPENCELLID_HEATMAP_GRADIENT_STOPS: ReadonlyArray = [ + [0.0, 'rgba(0, 0, 255, 0)'], + [0.05, 'rgba(0, 80, 255, 0.4)'], + [0.2, 'rgba(0, 140, 255, 0.65)'], + [0.4, 'rgba(0, 230, 220, 0.8)'], + [0.6, 'rgba(60, 220, 80, 0.85)'], + [0.8, 'rgba(255, 220, 0, 0.9)'], + [1.0, 'rgba(255, 40, 0, 0.95)'], +] +const EARTH_CIRCUMFERENCE_M = 40075016.686 +const TILE_SIZE_PX = 256 + +/* eslint-disable jsdoc/require-jsdoc -- helper return shapes; their property names are self-describing. */ +type BaseStationOverlayApi = { openConfigPanel: () => void } +type HeatmapSite = { lat: number; lon: number; rangeMeters: number } +type CellIdHeatLayerOptions = { sites: HeatmapSite[]; radiusFraction: number; opacity: number } +type CellIdHeatLayerInstance = L.Layer +/* eslint-enable jsdoc/require-jsdoc */ + +let cachedHeatGradientLut: Uint8ClampedArray | null = null +const heatmapGradientLut = (): Uint8ClampedArray => { + if (cachedHeatGradientLut) return cachedHeatGradientLut + const lutCanvas = document.createElement('canvas') + lutCanvas.width = 1 + lutCanvas.height = 256 + const ctx = lutCanvas.getContext('2d') + if (!ctx) throw new Error('Heatmap LUT: 2D canvas context unavailable') + const grad = ctx.createLinearGradient(0, 0, 0, 256) + OPENCELLID_HEATMAP_GRADIENT_STOPS.forEach(([stop, color]) => grad.addColorStop(stop, color)) + ctx.fillStyle = grad + ctx.fillRect(0, 0, 1, 256) + cachedHeatGradientLut = ctx.getImageData(0, 0, 1, 256).data + return cachedHeatGradientLut +} + +const metersPerPixelAt = (mapInstance: L.Map, lat: number): number => { + const pixelsAcrossEquator = TILE_SIZE_PX * 2 ** mapInstance.getZoom() + return (EARTH_CIRCUMFERENCE_M * Math.cos((lat * Math.PI) / 180)) / pixelsAcrossEquator +} + +// Custom Leaflet layer that renders the OpenCellID heatmap on a single canvas, with cumulative +// alpha → cool-to-warm color mapping. Built to replace the (uninstalled) `leaflet.heat` plugin +// so we can keep per-cell radius, eliminate canvas-edge clipping, and apply layer opacity once +// without it bleeding into the color ramp. +const CellIdHeatLayer = L.Layer.extend({ + /* eslint-disable jsdoc/require-jsdoc -- Leaflet layer prototype methods, not exported API. */ + initialize(this: CellIdHeatLayerInternal, options: CellIdHeatLayerOptions) { + this._heatOptions = { ...options } + }, + onAdd(this: CellIdHeatLayerInternal, mapInstance: L.Map) { + this._map = mapInstance + const canvas = L.DomUtil.create('canvas', 'leaflet-cellid-heat-layer leaflet-zoom-hide') + canvas.style.position = 'absolute' + canvas.style.pointerEvents = 'none' + canvas.style.opacity = String(this._heatOptions.opacity) + this._canvas = canvas + mapInstance.getPanes().overlayPane.appendChild(canvas) + mapInstance.on('moveend resize viewreset zoomend', this._reset, this) + this._reset() + return this + }, + onRemove(this: CellIdHeatLayerInternal, mapInstance: L.Map) { + if (this._frame !== undefined) { + cancelAnimationFrame(this._frame) + this._frame = undefined + } + mapInstance.getPanes().overlayPane.removeChild(this._canvas!) + mapInstance.off('moveend resize viewreset zoomend', this._reset, this) + this._canvas = undefined + this._map = undefined + return this + }, + _reset(this: CellIdHeatLayerInternal) { + if (!this._map || !this._canvas) return + const topLeft = this._map.containerPointToLayerPoint([0, 0]) + L.DomUtil.setPosition(this._canvas, topLeft) + const size = this._map.getSize() + if (this._canvas.width !== size.x) this._canvas.width = size.x + if (this._canvas.height !== size.y) this._canvas.height = size.y + this._scheduleRedraw() + }, + _scheduleRedraw(this: CellIdHeatLayerInternal) { + // Coalesce the costly full-canvas getImageData/putImageData sweep to one run per frame so a + // burst of pan/zoom events doesn't repaint every pixel multiple times in the same frame. + if (this._frame !== undefined) cancelAnimationFrame(this._frame) + this._frame = requestAnimationFrame(() => { + this._frame = undefined + this._redraw() + }) + }, + _redraw(this: CellIdHeatLayerInternal) { + if (!this._map || !this._canvas) return + const ctx = this._canvas.getContext('2d') + if (!ctx) return + const size = this._map.getSize() + ctx.clearRect(0, 0, size.x, size.y) + if (this._heatOptions.sites.length === 0) return + // Stage 1: accumulate per-cell radial gradients on the alpha channel. Using `lighter` + // makes overlapping cells brighten cumulatively → density per pixel. + ctx.globalCompositeOperation = 'lighter' + const mpp = metersPerPixelAt(this._map, this._map.getCenter().lat) + this._heatOptions.sites.forEach((site) => { + const center = this._map!.latLngToContainerPoint([site.lat, site.lon]) + const radiusPx = Math.max(2, (site.rangeMeters * this._heatOptions.radiusFraction) / mpp) + if ( + center.x + radiusPx < 0 || + center.x - radiusPx > size.x || + center.y + radiusPx < 0 || + center.y - radiusPx > size.y + ) { + return + } + const radial = ctx.createRadialGradient(center.x, center.y, 0, center.x, center.y, radiusPx) + radial.addColorStop(0, `rgba(255,255,255,${OPENCELLID_HEATMAP_PEAK_ALPHA})`) + radial.addColorStop(1, 'rgba(255,255,255,0)') + ctx.fillStyle = radial + ctx.fillRect(center.x - radiusPx, center.y - radiusPx, 2 * radiusPx, 2 * radiusPx) + }) + // Stage 2: remap each pixel's alpha through the cool→warm gradient LUT so density turns + // into color. The LUT also dictates the final pixel alpha so the natural radial fade is + // preserved while hotspots get punchier. + const lut = heatmapGradientLut() + const img = ctx.getImageData(0, 0, size.x, size.y) + const data = img.data + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3] + if (a === 0) continue + const lutIdx = a * 4 + data[i] = lut[lutIdx] + data[i + 1] = lut[lutIdx + 1] + data[i + 2] = lut[lutIdx + 2] + data[i + 3] = lut[lutIdx + 3] + } + ctx.putImageData(img, 0, 0) + }, + /* eslint-enable jsdoc/require-jsdoc */ +}) + +/* eslint-disable jsdoc/require-jsdoc -- internal layer fields, kept private to this module. */ +type CellIdHeatLayerInternal = L.Layer & { + _map?: L.Map + _canvas?: HTMLCanvasElement + _heatOptions: CellIdHeatLayerOptions + _frame?: number + _reset: () => void + _scheduleRedraw: () => void + _redraw: () => void +} +/* eslint-enable jsdoc/require-jsdoc */ + +const createCellIdHeatLayer = (options: CellIdHeatLayerOptions): CellIdHeatLayerInstance => { + const Ctor = CellIdHeatLayer as unknown as new (opts: CellIdHeatLayerOptions) => CellIdHeatLayerInstance + return new Ctor(options) +} + +const escapeMarkerLabel = (label: string): string => + label + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + +const baseStationMarkerHtml = (label: string, color: string): string => ` +
+
+ + ${label ? `
${escapeMarkerLabel(label)}
` : ''} +
+` + +/** + * 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. + */ +export const useBaseStationOverlay = ( + map: ShallowRef, + mapReady: Ref +): BaseStationOverlayApi => { + 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 mobileCoverageLayer = shallowRef() + const cachedOpenCellIdSites = shallowRef(null) + const cachedOverpassTowers = shallowRef(null) + + let mobileCoverageController: AbortController | null = null + let mobileCoverageTargetToolController: AbortController | null = null + let mobileCoverageDebounce: ReturnType | null = null + let detachMapDropHandlers: (() => void) | null = null + let detachTargetToolHandlers: (() => void) | null = null + let osmLabelOverlayEl: HTMLDivElement | null = null + let osmLabelSvgEl: SVGSVGElement | null = null + let osmLabelCleanup: (() => void) | null = null + let lastMarkerLabel: string | null = null + let lastMarkerColor: string | null = null + + const openConfigPanel = (): void => { + store.configPanelOpen = true + } + + const attachMapDropHandlers = (): void => { + // The host can share a ref that transiently holds a DOM element instead of the map (e.g. the + // mission-planning view's `planningMap` collides with a same-named Vue template ref), so bail + // unless we actually hold a live Leaflet instance. + if (!(map.value instanceof L.Map)) return + detachMapDropHandlers?.() + const container = map.value.getContainer() + const onDragOver = (event: DragEvent): void => { + if (!event.dataTransfer?.types.includes(MOBILE_COVERAGE_FETCH_DROP_MIME)) return + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + const onDrop = (event: DragEvent): void => { + if (!event.dataTransfer?.types.includes(MOBILE_COVERAGE_FETCH_DROP_MIME) || !map.value) return + event.preventDefault() + const rect = container.getBoundingClientRect() + const point = L.point(event.clientX - rect.left, event.clientY - rect.top) + const latLng = map.value.containerPointToLatLng(point) + void fetchAndAppendMobileCoverage([latLng.lat, latLng.lng]) + } + container.addEventListener('dragover', onDragOver) + container.addEventListener('drop', onDrop) + detachMapDropHandlers = () => { + container.removeEventListener('dragover', onDragOver) + container.removeEventListener('drop', onDrop) + detachMapDropHandlers = null + } + } + + // SVG-as-cursor: mdi-crosshairs-gps glyph on a transparent canvas so the cursor visually + // matches the toolbar icon while the operator is picking a point. + const TARGET_CURSOR_SVG = `` + const TARGET_CURSOR_URL = `url('data:image/svg+xml;utf8,${encodeURIComponent(TARGET_CURSOR_SVG)}') 12 12, crosshair` + + const attachTargetToolHandlers = (): void => { + if (!(map.value instanceof L.Map)) return + detachTargetToolHandlers?.() + const container = map.value.getContainer() + const previousCursor = container.style.cursor + container.style.cursor = TARGET_CURSOR_URL + const onMapClick = (event: L.LeafletMouseEvent): void => { + store.mobileCoverageTargetToolActive = false + void fetchAndAppendMobileCoverage([event.latlng.lat, event.latlng.lng]) + } + map.value.on('click', onMapClick) + detachTargetToolHandlers = () => { + container.style.cursor = previousCursor + map.value?.off('click', onMapClick) + detachTargetToolHandlers = null + } + } + + const removeLayer = (layer: L.Layer | undefined): void => { + if (layer && map.value) map.value.removeLayer(layer) + } + + const clearLoadedMobileCoverageData = (): void => { + cachedOpenCellIdSites.value = null + cachedOverpassTowers.value = null + store.availableOsmOperators = [] + store.availableOpenCellIdOperators = [] + store.config.mobileCoverage.openCellIdOperator = '' + } + + const openCellIdEntryForPosition = ( + position: WaypointCoordinates + ): CachedMobileCoverageEntry | undefined => + store.mobileCoverageCache.openCellId.find((entry) => bboxContains(entry.bbox, position)) + + const overpassEntryForPosition = ( + position: WaypointCoordinates + ): CachedMobileCoverageEntry | undefined => + store.mobileCoverageCache.osmOverpass.find((entry) => bboxContains(entry.bbox, position)) + + const loadOpenCellIdSitesFromStorage = (position: WaypointCoordinates): boolean => { + // Drop empty entries that cover this position — leftover from rate-limited fetches that + // returned 0 sites (was previously the H1 bug where empty caches blocked re-fetching). + const positionalEntry = openCellIdEntryForPosition(position) + if (positionalEntry && positionalEntry.data.length === 0) { + store.mobileCoverageCache.openCellId = store.mobileCoverageCache.openCellId.filter( + (cachedEntry) => !bboxEquals(cachedEntry.bbox, positionalEntry.bbox) + ) + } + // Union *every* cached entry so drag-target appends — whose bboxes don't necessarily + // contain the base station — still light up after a restart. + const merged = store.mobileCoverageCache.openCellId.reduce( + (acc, entry) => mergeOpenCellIdSites(acc, entry.data), + null + ) + cachedOpenCellIdSites.value = merged && merged.length > 0 ? merged : null + const operators = merged + ? [ + ...new Set(merged.map((site) => openCellIdOperatorLabel(site)).filter((label): label is string => !!label)), + ].sort() + : [] + store.availableOpenCellIdOperators = operators + if ( + store.config.mobileCoverage.openCellIdOperator && + !operators.includes(store.config.mobileCoverage.openCellIdOperator) + ) { + store.config.mobileCoverage.openCellIdOperator = '' + } + // Return the positional check so `fetchMobileCoverageData` only skips the fetch when the + // base station itself is already covered, regardless of how much side data we have. + return openCellIdEntryForPosition(position) !== undefined + } + + const loadOverpassTowersFromStorage = (position: WaypointCoordinates): boolean => { + const merged = store.mobileCoverageCache.osmOverpass.reduce( + (acc, entry) => mergeOverpassTowers(acc, entry.data), + null + ) + cachedOverpassTowers.value = merged && merged.length > 0 ? merged : null + store.availableOsmOperators = merged + ? [...new Set(merged.map((tower) => tower.operator).filter((operator): operator is string => !!operator))].sort() + : [] + return overpassEntryForPosition(position) !== undefined + } + + const resetVisibleMobileCoverageData = async (): Promise => { + if (!map.value) return + mobileCoverageController?.abort() + mobileCoverageController = null + if (mobileCoverageDebounce) { + clearTimeout(mobileCoverageDebounce) + mobileCoverageDebounce = null + } + const visibleArea = leafletBoundsToCoverageBbox(map.value.getBounds()) + const openCellIdBefore = store.mobileCoverageCache.openCellId.length + const overpassBefore = store.mobileCoverageCache.osmOverpass.length + store.mobileCoverageCache.openCellId = store.mobileCoverageCache.openCellId.filter( + (entry) => !bboxIntersects(entry.bbox, visibleArea) + ) + store.mobileCoverageCache.osmOverpass = store.mobileCoverageCache.osmOverpass.filter( + (entry) => !bboxIntersects(entry.bbox, visibleArea) + ) + const removedEntries = + openCellIdBefore - + store.mobileCoverageCache.openCellId.length + + (overpassBefore - store.mobileCoverageCache.osmOverpass.length) + clearLoadedMobileCoverageData() + if (store.config.position) { + if (store.config.mobileCoverage.provider === MobileCoverageProvider.OpenCellID) { + loadOpenCellIdSitesFromStorage(store.config.position) + } else if (store.config.mobileCoverage.provider === MobileCoverageProvider.OSMOverpass) { + loadOverpassTowersFromStorage(store.config.position) + } + } + teardownMobileCoverageData() + await renderMobileCoverage(store.config) + openSnackbar({ + variant: 'info', + message: + removedEntries > 0 + ? `Reset ${removedEntries} cached mobile coverage area${removedEntries === 1 ? '' : 's'} in view.` + : 'No cached mobile coverage data was stored for the current view.', + duration: 3000, + }) + } + + const storeOpenCellIdSites = (bbox: CoverageBbox, sites: OpenCellIdSite[]): void => { + cachedOpenCellIdSites.value = sites + const operators = [ + ...new Set(sites.map((site) => openCellIdOperatorLabel(site)).filter((label): label is string => !!label)), + ].sort() + store.availableOpenCellIdOperators = operators + if ( + store.config.mobileCoverage.openCellIdOperator && + !operators.includes(store.config.mobileCoverage.openCellIdOperator) + ) { + store.config.mobileCoverage.openCellIdOperator = '' + } + if (sites.length === 0) { + store.mobileCoverageCache.openCellId = store.mobileCoverageCache.openCellId.filter( + (entry) => !bboxEquals(entry.bbox, bbox) + ) + return + } + store.mobileCoverageCache.openCellId = trimCacheEntries([ + { + bbox, + fetchedAtMs: Date.now(), + data: sites, + }, + ...store.mobileCoverageCache.openCellId.filter((entry) => !bboxEquals(entry.bbox, bbox)), + ]) + } + + const storeOverpassTowers = (bbox: CoverageBbox, towers: OverpassTower[]): void => { + cachedOverpassTowers.value = towers + store.availableOsmOperators = [ + ...new Set(towers.map((tower) => tower.operator).filter((operator): operator is string => !!operator)), + ].sort() + store.mobileCoverageCache.osmOverpass = trimCacheEntries([ + { + bbox, + fetchedAtMs: Date.now(), + data: towers, + }, + ...store.mobileCoverageCache.osmOverpass.filter((entry) => !bboxEquals(entry.bbox, bbox)), + ]) + } + + const mergeOpenCellIdSites = (existing: OpenCellIdSite[] | null, incoming: OpenCellIdSite[]): OpenCellIdSite[] => { + const merged = new Map() + ;[...(existing ?? []), ...incoming].forEach((site) => { + const key = `${site.lat.toFixed(6)}:${site.lon.toFixed(6)}:${Math.round(site.rangeMeters)}` + merged.set(key, site) + }) + return [...merged.values()] + } + + const mergeOverpassTowers = (existing: OverpassTower[] | null, incoming: OverpassTower[]): OverpassTower[] => { + const merged = new Map() + ;[...(existing ?? []), ...incoming].forEach((tower) => { + merged.set(tower.id, tower) + }) + return [...merged.values()] + } + + const appendOpenCellIdSites = (bbox: CoverageBbox, sites: OpenCellIdSite[]): void => { + const entry = store.mobileCoverageCache.openCellId.find((cachedEntry) => bboxEquals(cachedEntry.bbox, bbox)) + const mergedEntrySites = mergeOpenCellIdSites(entry?.data ?? null, sites) + const mergedLoadedSites = mergeOpenCellIdSites(cachedOpenCellIdSites.value, sites) + cachedOpenCellIdSites.value = mergedLoadedSites + const operators = [ + ...new Set( + mergedLoadedSites.map((site) => openCellIdOperatorLabel(site)).filter((label): label is string => !!label) + ), + ].sort() + store.availableOpenCellIdOperators = operators + if ( + store.config.mobileCoverage.openCellIdOperator && + !operators.includes(store.config.mobileCoverage.openCellIdOperator) + ) { + store.config.mobileCoverage.openCellIdOperator = '' + } + store.mobileCoverageCache.openCellId = trimCacheEntries([ + { + bbox, + fetchedAtMs: Date.now(), + data: mergedEntrySites, + }, + ...store.mobileCoverageCache.openCellId.filter((cachedEntry) => !bboxEquals(cachedEntry.bbox, bbox)), + ]) + } + + const appendOverpassTowers = (bbox: CoverageBbox, towers: OverpassTower[]): void => { + const entry = store.mobileCoverageCache.osmOverpass.find((cachedEntry) => bboxEquals(cachedEntry.bbox, bbox)) + const mergedEntryTowers = mergeOverpassTowers(entry?.data ?? null, towers) + const mergedLoadedTowers = mergeOverpassTowers(cachedOverpassTowers.value, towers) + cachedOverpassTowers.value = mergedLoadedTowers + store.availableOsmOperators = [ + ...new Set( + mergedLoadedTowers.map((tower) => tower.operator).filter((operator): operator is string => !!operator) + ), + ].sort() + store.mobileCoverageCache.osmOverpass = trimCacheEntries([ + { + bbox, + fetchedAtMs: Date.now(), + data: mergedEntryTowers, + }, + ...store.mobileCoverageCache.osmOverpass.filter((cachedEntry) => !bboxEquals(cachedEntry.bbox, bbox)), + ]) + } + + const buildMarkerIcon = (label: string, color: string): L.DivIcon => + L.divIcon({ + className: 'base-station-marker-icon', + html: baseStationMarkerHtml(label, color), + iconSize: [24, 24], + 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 + const markerLabel = config.name.trim() + if (marker.value) { + marker.value.setLatLng(config.position) + // setIcon during a drag rebuilds the DOM element Leaflet is tracking and stops the drag + // after the first few pixels, so only rebuild when the icon definition actually changed. + if (markerLabel !== lastMarkerLabel || config.coverageColor !== lastMarkerColor) { + marker.value.setIcon(buildMarkerIcon(markerLabel, config.coverageColor)) + lastMarkerLabel = markerLabel + lastMarkerColor = config.coverageColor + } + return + } + const m = L.marker(config.position, { + icon: buildMarkerIcon(markerLabel, config.coverageColor), + draggable: true, + zIndexOffset: 600, + // The marker owns its own right-click popup; don't propagate to the map context menu. + bubblingMouseEvents: false, + }) + lastMarkerLabel = markerLabel + lastMarkerColor = config.coverageColor + 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 ensureOsmLabelOverlay = (): void => { + if (!map.value || osmLabelOverlayEl) return + const container = map.value.getContainer() + osmLabelOverlayEl = document.createElement('div') + osmLabelOverlayEl.style.position = 'absolute' + osmLabelOverlayEl.style.top = '0' + osmLabelOverlayEl.style.left = '0' + osmLabelOverlayEl.style.width = '100%' + osmLabelOverlayEl.style.height = '100%' + osmLabelOverlayEl.style.pointerEvents = 'none' + osmLabelOverlayEl.style.zIndex = '620' + + osmLabelSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + osmLabelSvgEl.setAttribute('width', '100%') + osmLabelSvgEl.setAttribute('height', '100%') + osmLabelSvgEl.style.position = 'absolute' + osmLabelSvgEl.style.top = '0' + osmLabelSvgEl.style.left = '0' + + osmLabelOverlayEl.appendChild(osmLabelSvgEl) + container.appendChild(osmLabelOverlayEl) + } + + const teardownOsmLabelOverlay = (): void => { + if (osmLabelCleanup) { + osmLabelCleanup() + osmLabelCleanup = null + } + if (osmLabelOverlayEl) { + osmLabelOverlayEl.remove() + osmLabelOverlayEl = null + } + osmLabelSvgEl = null + } + + const pointOnArc = (center: L.Point, radiusPx: number, angleDeg: number): L.Point => { + const radians = ((angleDeg - 90) * Math.PI) / 180 + return L.point(center.x + radiusPx * Math.cos(radians), center.y + radiusPx * Math.sin(radians)) + } + + const svgArcPath = (center: L.Point, radiusPx: number, startDeg: number, endDeg: number): string => { + let normalizedEnd = endDeg + while (normalizedEnd <= startDeg) normalizedEnd += 360 + const start = pointOnArc(center, radiusPx, startDeg) + const end = pointOnArc(center, radiusPx, normalizedEnd) + const largeArc = normalizedEnd - startDeg > 180 ? 1 : 0 + return `M ${start.x.toFixed(1)} ${start.y.toFixed(1)} A ${radiusPx.toFixed(1)} ${radiusPx.toFixed( + 1 + )} 0 ${largeArc} 1 ${end.x.toFixed(1)} ${end.y.toFixed(1)}` + } + + const renderOsmCoverageLabels = (labels: OsmCoverageLabelSpec[]): void => { + if (!map.value || labels.length === 0) { + teardownOsmLabelOverlay() + return + } + ensureOsmLabelOverlay() + if (!osmLabelSvgEl) return + osmLabelSvgEl.replaceChildren() + + labels.forEach((labelSpec) => { + const center = labelSpec.center + const centerPoint = map.value!.latLngToContainerPoint(center) + const radiusPoint = map.value!.latLngToContainerPoint(bearingHandlePosition(center, labelSpec.rangeMeters, 90)) + const radiusPx = centerPoint.distanceTo(radiusPoint) * OSM_LABEL_RIM_INSET + if (radiusPx < 24) return + + const pathStart = + labelSpec.bearing === null + ? OSM_TOP_ARC_START_DEG + : labelSpec.bearing - labelSpec.beamwidth / 2 + Math.min(8, labelSpec.beamwidth * 0.15) + const pathEnd = + labelSpec.bearing === null + ? OSM_TOP_ARC_END_DEG + : labelSpec.bearing + labelSpec.beamwidth / 2 - Math.min(8, labelSpec.beamwidth * 0.15) + const angleSpan = + labelSpec.bearing === null ? 120 : Math.max(24, labelSpec.beamwidth - Math.min(16, labelSpec.beamwidth * 0.3)) + const maxWidthPx = radiusPx * ((angleSpan * Math.PI) / 180) + const text = fitLabelToArc(labelSpec.labelParts, maxWidthPx) + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path.setAttribute('id', labelSpec.id) + path.setAttribute('d', svgArcPath(centerPoint, radiusPx, pathStart, pathEnd)) + path.setAttribute('fill', 'none') + path.setAttribute('stroke', 'none') + + const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text') + textEl.setAttribute('font-size', `${OSM_LABEL_FONT_SIZE_PX}`) + textEl.setAttribute('font-family', 'sans-serif') + textEl.setAttribute('fill', labelSpec.color) + textEl.setAttribute('fill-opacity', `${store.config.mobileCoverage.overlayOpacity}`) + textEl.setAttribute('stroke', 'rgba(0, 0, 0, 0.55)') + textEl.setAttribute('stroke-width', '2') + textEl.setAttribute('stroke-opacity', `${Math.max(0.35, store.config.mobileCoverage.overlayOpacity)}`) + textEl.setAttribute('paint-order', 'stroke') + textEl.setAttribute('letter-spacing', '0.2') + + const textPath = document.createElementNS('http://www.w3.org/2000/svg', 'textPath') + textPath.setAttributeNS('http://www.w3.org/1999/xlink', 'href', `#${labelSpec.id}`) + textPath.setAttribute('href', `#${labelSpec.id}`) + textPath.setAttribute('startOffset', '50%') + textPath.setAttribute('text-anchor', 'middle') + textPath.textContent = text + + textEl.appendChild(textPath) + osmLabelSvgEl.appendChild(path) + osmLabelSvgEl.appendChild(textEl) + }) + } + + const renderOpenCellIdCoverageRings = (sites: OpenCellIdSite[], config: BaseStationConfig): void => { + if (!map.value) return + const filteredSites = filterOpenCellIdSites(sites, config.mobileCoverage.openCellIdOperator) + if (filteredSites.length === 0) return + const group = L.layerGroup() + const labels: OsmCoverageLabelSpec[] = [] + filteredSites.forEach((site, index) => { + L.circle([site.lat, site.lon], { + radius: site.rangeMeters, + color: config.coverageColor, + weight: 1, + dashArray: '5 5', + opacity: config.mobileCoverage.overlayOpacity, + fillColor: config.coverageColor, + fillOpacity: OPENCELLID_RING_FILL_OPACITY * config.mobileCoverage.overlayOpacity, + interactive: false, + }).addTo(group) + labels.push({ + id: `open-cell-id-label-${index}`, + center: [site.lat, site.lon], + rangeMeters: site.rangeMeters, + bearing: null, + beamwidth: 360, + labelParts: openCellIdLabelParts(site), + color: config.coverageColor, + }) + }) + group.addTo(map.value) + mobileCoverageLayer.value = group + if (!config.mobileCoverage.showRingLabels) return + renderOsmCoverageLabels(labels) + const rerenderLabels = (): void => renderOsmCoverageLabels(labels) + map.value.on('move zoom resize', rerenderLabels) + osmLabelCleanup = () => map.value?.off('move zoom resize', rerenderLabels) + } + + 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 teardownRenderedMobileCoverage = (): void => { + if (mobileCoverageController) { + mobileCoverageController.abort() + mobileCoverageController = null + } + if (mobileCoverageTargetToolController) { + mobileCoverageTargetToolController.abort() + mobileCoverageTargetToolController = null + } + if (mobileCoverageDebounce) { + clearTimeout(mobileCoverageDebounce) + mobileCoverageDebounce = null + } + teardownOsmLabelOverlay() + removeLayer(mobileCoverageLayer.value) + mobileCoverageLayer.value = undefined + } + + const teardownMobileCoverageData = (): void => { + teardownRenderedMobileCoverage() + clearLoadedMobileCoverageData() + } + + const mobileHeatmapRadiusFraction = (config: BaseStationConfig, intensityBoost = 1): number => + OPENCELLID_HEATMAP_MIN_RADIUS_FRACTION + + Math.max(0, Math.min(1, config.mobileCoverage.heatmapIntensity * intensityBoost)) * + (1 - OPENCELLID_HEATMAP_MIN_RADIUS_FRACTION) + + const renderOpenCellIdHeatmap = (sites: OpenCellIdSite[], config: BaseStationConfig): void => { + if (!map.value) return + const filteredSites = filterOpenCellIdSites(sites, config.mobileCoverage.openCellIdOperator) + if (filteredSites.length === 0) return + + const heat = createCellIdHeatLayer({ + sites: filteredSites, + radiusFraction: mobileHeatmapRadiusFraction(config), + opacity: config.mobileCoverage.overlayOpacity, + }) + heat.addTo(map.value) + mobileCoverageLayer.value = heat + } + + const renderOsmHeatmap = (towers: OverpassTower[], config: BaseStationConfig): void => { + if (!map.value || towers.length === 0) return + const selectedOperator = config.mobileCoverage.osmOperator + const heatSites = (selectedOperator ? towers.filter((tower) => tower.operator === selectedOperator) : towers).map( + (tower) => ({ + lat: tower.lat, + lon: tower.lon, + rangeMeters: overpassRangeMeters(tower.tags), + }) + ) + if (heatSites.length === 0) return + const heat = createCellIdHeatLayer({ + sites: heatSites, + radiusFraction: mobileHeatmapRadiusFraction(config, 1.5), + opacity: config.mobileCoverage.overlayOpacity, + }) + heat.addTo(map.value) + mobileCoverageLayer.value = heat + } + + const renderOsmCoverage = (towers: OverpassTower[], config: BaseStationConfig): void => { + if (!map.value || towers.length === 0) return + const selectedOperator = config.mobileCoverage.osmOperator + const filtered = selectedOperator ? towers.filter((t) => t.operator === selectedOperator) : towers + if (filtered.length === 0) return + + const group = L.layerGroup() + const labels: OsmCoverageLabelSpec[] = [] + + filtered.forEach((tower) => { + const center = [tower.lat, tower.lon] as WaypointCoordinates + const bearing = overpassBearing(tower.tags) + const beamwidth = overpassBeamwidth(tower.tags, bearing) + const rangeMeters = overpassRangeMeters(tower.tags) + const color = operatorColor(tower.operator) + + if (bearing === null || beamwidth >= 360) { + L.circle(center, { + radius: rangeMeters, + color, + weight: 1, + dashArray: '5 5', + opacity: OSM_COVERAGE_STROKE_OPACITY * config.mobileCoverage.overlayOpacity, + fillColor: color, + fillOpacity: OSM_COVERAGE_FILL_OPACITY * config.mobileCoverage.overlayOpacity, + interactive: false, + }).addTo(group) + } else { + L.polygon(sectorPolygonLatLngs(center, rangeMeters, bearing, beamwidth), { + color, + weight: 1, + dashArray: '5 5', + opacity: OSM_COVERAGE_STROKE_OPACITY * config.mobileCoverage.overlayOpacity, + fillColor: color, + fillOpacity: OSM_COVERAGE_FILL_OPACITY * config.mobileCoverage.overlayOpacity, + interactive: false, + }).addTo(group) + } + + labels.push({ + id: `osm-coverage-label-${tower.id}`, + center, + rangeMeters, + bearing, + beamwidth, + labelParts: overpassLabelParts(tower), + color, + }) + }) + + group.addTo(map.value) + mobileCoverageLayer.value = group + if (!config.mobileCoverage.showRingLabels) return + renderOsmCoverageLabels(labels) + + const rerenderLabels = (): void => renderOsmCoverageLabels(labels) + map.value.on('move zoom resize', rerenderLabels) + osmLabelCleanup = () => map.value?.off('move zoom resize', rerenderLabels) + } + + const renderMobileCoverage = async (config: BaseStationConfig): Promise => { + teardownRenderedMobileCoverage() + + 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: config.mobileCoverage.overlayOpacity }).addTo(map.value) + return + } + + if (provider === MobileCoverageProvider.OpenCellID) { + const sites = + cachedOpenCellIdSites.value ?? + (loadOpenCellIdSitesFromStorage(config.position) ? cachedOpenCellIdSites.value : null) + if (!sites || sites.length === 0) return + if (config.mobileCoverage.displayMode === MobileCoverageDisplayMode.CoverageRings) { + renderOpenCellIdCoverageRings(sites, config) + return + } + renderOpenCellIdHeatmap(sites, config) + return + } + + const towers = + cachedOverpassTowers.value ?? (loadOverpassTowersFromStorage(config.position) ? cachedOverpassTowers.value : null) + if (!towers || towers.length === 0) return + if (config.mobileCoverage.displayMode === MobileCoverageDisplayMode.Heatmap) { + renderOsmHeatmap(towers, config) + return + } + renderOsmCoverage(towers, config) + } + + const fetchAndAppendMobileCoverage = async (position: WaypointCoordinates): Promise => { + const provider = store.config.mobileCoverage.provider + if (provider === MobileCoverageProvider.Custom) { + openSnackbar({ + variant: 'info', + message: 'Custom overlays cannot be fetched from the map target tool.', + duration: 3500, + }) + return + } + + if (mobileCoverageTargetToolController) mobileCoverageTargetToolController.abort() + const controller = new AbortController() + mobileCoverageTargetToolController = controller + store.mobileCoverageLoading = true + try { + if (provider === MobileCoverageProvider.OpenCellID) { + const apiKey = store.config.mobileCoverage.openCellIdApiKey.trim() + if (!apiKey) { + store.openCellIdApiKeyStatus = 'unknown' + notifyOpenCellIdKeyRequired() + return + } + const { sites, fetchedBbox } = await fetchOpenCellIdSites(position, apiKey, controller.signal) + if (controller.signal.aborted) return + appendOpenCellIdSites(fetchedBbox, sites) + store.openCellIdApiKeyStatus = apiKey ? 'valid' : 'unknown' + if (sites.length === 0) { + openSnackbar({ + variant: 'info', + message: `${provider} returned no cellular data around the dropped target.`, + duration: 4000, + }) + } else { + openSnackbar({ + variant: 'success', + message: `Added ${sites.length} OpenCellID sites around the dropped target.`, + duration: 3000, + }) + } + } else { + const bbox = overpassBboxAround(position[0], position[1]) + const towers = await fetchOverpassTowers(bbox, controller.signal) + if (controller.signal.aborted) return + appendOverpassTowers(bbox, towers) + if (towers.length === 0) { + openSnackbar({ + variant: 'info', + message: `${provider} returned no cellular data around the dropped target.`, + duration: 4000, + }) + } else { + openSnackbar({ + variant: 'success', + message: `Added ${towers.length} OSM towers around the dropped target.`, + duration: 3000, + }) + } + } + await renderMobileCoverage(store.config) + } catch (err) { + if ((err as DOMException)?.name === 'AbortError') return + const errorMessage = (err as Error).message + if (provider === MobileCoverageProvider.OpenCellID && store.config.mobileCoverage.openCellIdApiKey.trim()) { + if (isOpenCellIdInvalidApiKeyError(errorMessage)) { + store.openCellIdApiKeyStatus = 'invalid' + openSnackbar({ + variant: 'error', + message: 'OpenCellID API key is invalid. Check the key and try again.', + duration: 4000, + }) + return + } + store.openCellIdApiKeyStatus = 'unknown' + } + openSnackbar({ + variant: 'error', + message: `Mobile coverage fetch failed: ${errorMessage}`, + duration: 4000, + }) + } finally { + if (mobileCoverageTargetToolController === controller) mobileCoverageTargetToolController = null + store.mobileCoverageLoading = false + } + } + + const fetchMobileCoverageData = async (config: BaseStationConfig, forceReload = false): Promise => { + if (!map.value || !config.enabled || !config.position) return + if (config.commsType !== BaseStationCommsType.MobileData) return + + const provider = config.mobileCoverage.provider + if (provider === MobileCoverageProvider.Custom) { + await renderMobileCoverage(config) + return + } + + const controller = new AbortController() + mobileCoverageController = controller + store.mobileCoverageLoading = true + try { + if (provider === MobileCoverageProvider.OpenCellID) { + const apiKey = config.mobileCoverage.openCellIdApiKey.trim() + if (!apiKey) { + store.openCellIdApiKeyStatus = 'unknown' + notifyOpenCellIdKeyRequired() + return + } + if (!forceReload && loadOpenCellIdSitesFromStorage(config.position)) { + await renderMobileCoverage(config) + return + } + const { sites, fetchedBbox } = await fetchOpenCellIdSites(config.position, apiKey, controller.signal) + if (controller.signal.aborted) return + storeOpenCellIdSites(fetchedBbox, sites) + if (apiKey) store.openCellIdApiKeyStatus = 'valid' + if (sites.length === 0) { + openSnackbar({ + variant: 'info', + message: `${provider} returned no cellular data around the base station.`, + duration: 4000, + }) + return + } + } else { + const coverageBbox = overpassBboxAround(config.position[0], config.position[1]) + if (!forceReload && loadOverpassTowersFromStorage(config.position)) { + await renderMobileCoverage(config) + return + } + const towers = await fetchOverpassTowers(coverageBbox, controller.signal) + if (controller.signal.aborted) return + storeOverpassTowers(coverageBbox, towers) + if (towers.length === 0) { + openSnackbar({ + variant: 'info', + message: `${provider} returned no cellular data around the base station.`, + duration: 4000, + }) + return + } + } + + await renderMobileCoverage(config) + } catch (err) { + if ((err as DOMException)?.name === 'AbortError') return + const errorMessage = (err as Error).message + if (provider === MobileCoverageProvider.OpenCellID && config.mobileCoverage.openCellIdApiKey.trim()) { + if (isOpenCellIdInvalidApiKeyError(errorMessage)) { + store.openCellIdApiKeyStatus = 'invalid' + openSnackbar({ + variant: 'error', + message: 'OpenCellID API key is invalid. Check the key and try again.', + duration: 4000, + }) + return + } + store.openCellIdApiKeyStatus = 'unknown' + } + if ( + provider === MobileCoverageProvider.OpenCellID && + config.position && + loadOpenCellIdSitesFromStorage(config.position) && + cachedOpenCellIdSites.value?.length + ) { + await renderMobileCoverage(config) + return + } + openSnackbar({ + variant: 'error', + message: `Mobile coverage fetch failed: ${errorMessage}`, + duration: 4000, + }) + } finally { + if (mobileCoverageController === controller) mobileCoverageController = null + store.mobileCoverageLoading = false + } + } + + const refreshAll = (): void => { + if (!mapReady.value || !(map.value instanceof L.Map)) return + const config = store.config + + if (!config.enabled || !config.position) { + removeLayer(marker.value) + removeLayer(coverageLayer.value) + removeLayer(tetherLayer.value) + removeLayer(bearingHandle.value) + removeLayer(bearingLine.value) + removeLayer(aimingArc.value) + teardownMobileCoverageData() + marker.value = undefined + coverageLayer.value = undefined + tetherLayer.value = undefined + bearingHandle.value = undefined + bearingLine.value = undefined + aimingArc.value = undefined + lastMarkerLabel = null + lastMarkerColor = null + return + } + + ensureMarker(config) + updateCoverage(config) + updateTether(config) + updateBearingHandle(config) + } + + watch([map, mapReady], refreshAll, { immediate: true }) + watch( + [map, mapReady], + () => { + if (!mapReady.value) return + attachMapDropHandlers() + }, + { 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.position, + ], + () => { + teardownMobileCoverageData() + mobileCoverageDebounce = setTimeout(() => void fetchMobileCoverageData(store.config), 500) + }, + { immediate: true } + ) + + watch( + () => store.mobileCoverageReloadToken, + () => { + teardownMobileCoverageData() + mobileCoverageDebounce = setTimeout(() => void fetchMobileCoverageData(store.config, true), 100) + } + ) + + watch( + () => store.mobileCoverageVisibleDataResetToken, + () => { + void resetVisibleMobileCoverageData() + } + ) + + // Visual-only re-render. Provider/commsType/customTileUrl/position are already covered by + // the fetch watcher above — pulling them in here would cause a render against the empty + // cache before the fetch completes. + watch( + () => [ + mapReady.value, + store.config.mobileCoverage.displayMode, + store.config.mobileCoverage.overlayOpacity, + store.config.mobileCoverage.osmOperator, + store.config.mobileCoverage.openCellIdOperator, + store.config.mobileCoverage.showRingLabels, + store.config.mobileCoverage.heatmapIntensity, + store.config.coverageColor, + ], + () => { + void renderMobileCoverage(store.config) + }, + { immediate: true } + ) + + watch( + () => [mapReady.value, store.mobileCoverageTargetToolActive] as const, + ([ready, active]) => { + if (!ready || !(map.value instanceof L.Map)) return + if (active) attachTargetToolHandlers() + else detachTargetToolHandlers?.() + }, + { immediate: true } + ) + + onBeforeUnmount(() => { + detachMapDropHandlers?.() + detachTargetToolHandlers?.() + teardownMobileCoverageData() + 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/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 new file mode 100644 index 0000000000..1b34235b50 --- /dev/null +++ b/src/types/baseStation.ts @@ -0,0 +1,360 @@ +import type { WaypointCoordinates } from '@/types/mission' + +/** + * Topside computer mount type. Portable means the base station may move during deployment, + * while fixed stays at a stationary location. + */ +export enum TopSideComputerType { + Portable = 'Portable', + 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', +} + +/** + * 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 const MOBILE_COVERAGE_FETCH_DROP_MIME = 'application/x-cockpit-mobile-coverage-fetch' + +/** + * Visualization mode for the mobile coverage overlay. + */ +export enum MobileCoverageDisplayMode { + Heatmap = 'Heatmap', + CoverageRings = 'Coverage rings', +} + +export type MobileCoverageConfig = { + /** + * Active coverage data provider. + */ + provider: MobileCoverageProvider + /** + * OpenCellID API key. Required when {@link provider} is {@link MobileCoverageProvider.OpenCellID}. + */ + openCellIdApiKey: string + /** + * Selected OpenCellID operator/network code label. Empty string keeps all returned networks. + */ + openCellIdOperator: 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 + /** + * Visualization mode for OpenCellID overlays. + */ + displayMode: MobileCoverageDisplayMode + /** + * Opacity multiplier (0..1) applied to mobile coverage overlays. + */ + overlayOpacity: number + /** + * Show the operator/technology labels rendered along the rim of each coverage ring. + */ + showRingLabels: boolean + /** + * Heatmap intensity (0..1). Scales the heat radius so 0 keeps a tight, low-opacity blob and + * 1 spreads it out to roughly match the coverage rings. + */ + heatmapIntensity: number +} + +/* eslint-disable jsdoc/require-jsdoc -- Self-describing geo bbox in WGS84 degrees. */ +export type CoverageBbox = { south: number; west: number; north: number; east: number } +/* eslint-enable jsdoc/require-jsdoc */ + +/* eslint-disable jsdoc/require-jsdoc -- OpenCellID transport DTO; fields mirror the upstream API. */ +export type CachedOpenCellIdSite = { + lat: number + lon: number + rangeMeters: number + radio?: string + mcc?: number + mnc?: number + lac?: number + cellId?: number + samples?: number + averageSignalStrength?: number +} +/* eslint-enable jsdoc/require-jsdoc */ + +/* eslint-disable jsdoc/require-jsdoc -- OSM Overpass transport DTO; fields mirror the upstream API. */ +export type CachedOverpassTower = { + id: number + lat: number + lon: number + operator: string | null + tags: Record +} +/* eslint-enable jsdoc/require-jsdoc */ + +/* eslint-disable jsdoc/require-jsdoc -- Generic cache envelope; fields are self-describing. */ +export type CachedMobileCoverageEntry = { + bbox: CoverageBbox + fetchedAtMs: number + data: T[] +} + +export type MobileCoverageCache = { + openCellId: CachedMobileCoverageEntry[] + osmOverpass: CachedMobileCoverageEntry[] +} +/* eslint-enable jsdoc/require-jsdoc */ + +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. + */ + enabled: boolean + /** + * Optional label shown under the marker when non-empty. + */ + name: string + /** + * 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 + /** + * 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. + */ + 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, + name: '', + 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, + mobileCoverage: { + provider: MobileCoverageProvider.OpenCellID, + openCellIdApiKey: '', + openCellIdOperator: '', + customTileUrl: '', + osmOperator: '', + displayMode: MobileCoverageDisplayMode.Heatmap, + overlayOpacity: 0.3, + showRingLabels: true, + heatmapIntensity: 0.5, + }, + coverageColor: '#3B82F6', + coverageOpacity: 1, +} + +export const DEFAULT_MOBILE_COVERAGE_CACHE: MobileCoverageCache = { + openCellId: [], + osmOverpass: [], +} + +/** + * 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 */ diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index dfe3669030..8889a9e69d 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -106,7 +106,7 @@ />
@@ -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 +1974,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]]