From 826d3b344e001daa6660808a2620f591bec3a352 Mon Sep 17 00:00:00 2001 From: Joshua Gorman <90810000+endevii@users.noreply.github.com> Date: Wed, 10 Apr 2024 00:04:07 -0400 Subject: [PATCH 1/2] Added playground to test frequencies and added overlapping frequency detection. (#191) * Started redux implementation * feat: basic playground store creation * feat(playground): Finished playground infra w/ redux imple * feat(overlaps): Finished lobe overlap detection implementation with turfjs * fix(overlaps): Fixed build error and panning on unrelated events --- app/StoreProvider.tsx | 18 + app/accessPointTypes.ts | 224 ++++++++++++ app/components/Antennas.tsx | 12 +- app/components/Map.tsx | 336 +++++++++++++++++- app/components/SectorLobe.tsx | 176 ++++++--- app/components/SectorLobes.tsx | 55 ++- app/page.tsx | 5 +- app/types.ts | 17 +- lib/features/actual/actualSlice.ts | 50 +++ .../currentAntennas/currentAntennasSlice.ts | 42 +++ lib/features/playground/playgroundSlice.ts | 55 +++ lib/features/sectorlobes/sectorlobesSlice.ts | 50 +++ lib/hooks.ts | 8 + lib/store.ts | 22 ++ package-lock.json | 214 ++++++++--- package.json | 8 +- 16 files changed, 1151 insertions(+), 141 deletions(-) create mode 100644 app/StoreProvider.tsx create mode 100644 app/accessPointTypes.ts create mode 100644 lib/features/actual/actualSlice.ts create mode 100644 lib/features/currentAntennas/currentAntennasSlice.ts create mode 100644 lib/features/playground/playgroundSlice.ts create mode 100644 lib/features/sectorlobes/sectorlobesSlice.ts create mode 100644 lib/hooks.ts create mode 100644 lib/store.ts diff --git a/app/StoreProvider.tsx b/app/StoreProvider.tsx new file mode 100644 index 0000000..3e5368d --- /dev/null +++ b/app/StoreProvider.tsx @@ -0,0 +1,18 @@ +'use client'; +import { useRef } from 'react'; +import { Provider } from 'react-redux'; +import { makeStore, AppStore } from '../lib/store'; + +export default function StoreProvider({ + children, +}: { + children: React.ReactNode; +}) { + const storeRef = useRef(); + if (!storeRef.current) { + // Create the store instance the first time this renders + storeRef.current = makeStore(); + } + + return {children}; +} diff --git a/app/accessPointTypes.ts b/app/accessPointTypes.ts new file mode 100644 index 0000000..8ea1c8d --- /dev/null +++ b/app/accessPointTypes.ts @@ -0,0 +1,224 @@ +type Site = { + id: string | null; + name: string | null; + status: string | null; + type: string | null; + parent: Site | null; +}; + +type PSU = { + psuType: string | null; + connected: boolean | null; + maxChargingPower: number | null; + voltage: number | null; + power: number | null; + batteryCapacity: number | null; + batteryTime: number | null; + batteryType: string | null; +}; + +type MainInterfaceSpeed = { + interfaceId: string | null; + availableSpeed: string | null; +}; + +type Antenna = { + builtIn: boolean | null; + cableLoss: number | null; + gain: number | null; + id: string | null; + name: string | null; +}; + +type Overview = { + antenna: Antenna | null; + downlinkCapacity: number | null; + totalCapacity: number | null; + downlinkUtilization: number | null; + theoreticalTotalCapacity: number | null; + theoreticalDownlinkCapacity: number | null; + theoreticalUplinkCapacity: number | null; + uplinkCapacity: number | null; + uplinkUtilization: number | null; + stationsCount: number | null; + linkStationsCount: number | null; + linkActiveStationsCount: number | null; + runningOnBattery: boolean | null; + status: string | null; + canUpgrade: boolean | null; + isLocateRunning: boolean | null; + cpu: number | null; + ram: number | null; + signal: number | null; + signalMax: number | null; + remoteSignalMax: number | null; + uptime: number | null; + serviceUptime: number | null; + serviceTime: number | null; + distance: number | null; + outageScore: number | null; + lastSeen: string | null; + createdAt: string | null; + voltage: number | null; + consumption: number | null; + biasCurrent: number | null; + outputPower: number | null; + outputPowers: number[] | null; + maximalPower: number | null; + frequency: number | null; + temperature: number | null; + powerStatus: string | null; + batteryCapacity: number | null; + batteryTime: number | null; + psu: PSU[] | null; + linkScore: LinkScore | null; + channelWidth: number | null; + transmitPower: number | null; + wirelessMode: string | null; + wirelessActiveInterfaceIds: string[] | null; + mainInterfaceSpeed: MainInterfaceSpeed | null; +}; + +type Semver = { + major: number | null; + minor: number | null; + patch: number | null; + prerelease: string[] | null; + order: string | null; +}; + +type Firmware = { + compatible: boolean | null; + current: string | null; + latest: string | null; + latestOnCurrentMajorVersion: string | null; + latestOver: string | null; + upgradeRecommendedToVersion: string | null; + prospective: string | null; + semver: { + current: Semver | null; + latest: Semver | null; + latestOver: Semver | null; + latestOnCurrentMajorVersion: Semver | null; + }; +}; + +type LatestBackup = { + timestamp: string | null; + id: string | null; +}; + +type Configuration = { + id: string | null; + status: string | null; + hash: string | null; + createdAt: string | null; +}; + +type Location = { + altitude: number | null; + elevation: number | null; + heading: number | null; + latitude: number; + longitude: number; + magneticHeading: number | null; + roll: number | null; + tilt: number | null; +}; + +type Features = { + has60GhzRadio: boolean | null; + hasBackupAntenna: boolean | null; + isUdapiSpeedTestSupported: boolean | null; + isUsingUdapiUpdaters: boolean | null; + isSupportRouter: boolean | null; +}; + +type Meta = { + firmwareCompatibility: string | null; + failedMessageDecryption: boolean | null; + maintenance: boolean | null; + maintenanceEnabledAt: string | null; + restartTimestamp: string | null; + alias: string | null; + note: string | null; +}; + +type Attributes = { + series?: string | null; + ssid: string | null; + secondarySsid?: string | null; + apDevice: string | null; + country: string | null; + countryCode: number | null; +}; + +type LinkScore = { + linkScore: number | null; + score: number | null; + scoreMax: number | null; + airTime: number | null; + airTimeScore: number | null; + linkScoreHint: string | null; + theoreticalTotalCapacity: number | null; + theoreticalDownlinkCapacity: number | null; + theoreticalUplinkCapacity: number | null; +}; + +type Upgrade = { + status: string | null; + progress: number | null; + firmwareVersion: string | null; + firmware: { + major: number | null; + minor: number | null; + patch: number | null; + prerelease: string[] | null; + order: string | null; + }; + upgradeInMaintenanceWindow: boolean | null; +}; + +export type Device = { + identification: { + id: string; + site: Site | null; + mac: string | null; + name: string | null; + hostname: string | null; + serialNumber: string | null; + firmwareVersion: string | null; + udapiVersion: string | null; + bridgeVersion: string | null; + subsystemId: string | null; + model: string | null; + modelName: string; + systemName: string | null; + vendor: string | null; + vendorName: string | null; + platformId: string | null; + platformName: string; + type: string | null; + category: string | null; + authorized: boolean | null; + updated: string | null; + started: string | null; + displayName: string | null; + role: string | null; + }; + uplinkDevice: string | null; + features: Features; + overview: Overview; + discovery: string | null; + mode: string | null; + firmware: Firmware; + upgrade: Upgrade | null; + meta: Meta | null; + attributes: Attributes | null; + ipAddress: string | null; + ipAddressList: string[]; + enabled: boolean | null; + latestBackup: LatestBackup | null; + configuration: Configuration | null; + location: Location; +}; diff --git a/app/components/Antennas.tsx b/app/components/Antennas.tsx index bb4dd96..28a831f 100644 --- a/app/components/Antennas.tsx +++ b/app/components/Antennas.tsx @@ -1,4 +1,7 @@ import { Circle, Popup } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; + +// Types are temporary until the API is up and running import { AccessPoint, InfoProps, @@ -6,14 +9,17 @@ import { ReducedContent, } from '../types'; +import { useAppSelector } from '../../lib/hooks'; + export default function Antennas({ currentAntenna, setCurrentAntenna, getToggle, - antennasData, changeToggle, }: InfoProps) { - const antennas: AccessPoint[] = antennasData; + const antennasData = useAppSelector((state) => state.currentAntennas.value); + + const antennas: AccessPoint[] = antennasData.data; const reducedAntennas: ReducedPoints = {}; for (let i = 0; i < antennas.length; i++) { @@ -82,7 +88,7 @@ export default function Antennas({ > {/* Create a popup which has the names of all antennas at some node */} -
+
Access Points: {value[1].points.length} {convertPoints(value[1].points)}
diff --git a/app/components/Map.tsx b/app/components/Map.tsx index af8be5d..caae758 100644 --- a/app/components/Map.tsx +++ b/app/components/Map.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { MapContainer, TileLayer, @@ -7,14 +7,71 @@ import { LayerGroup, useMap, } from 'react-leaflet'; -import L from 'leaflet'; + +import L, { LatLngExpression } from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import { intersect } from '@turf/intersect'; +import { polygon } from '@turf/helpers'; +import { Position } from 'geojson'; + import Antennas from './Antennas'; import SectorLobes from './SectorLobes'; import AntennaInfo from './AntennaInfo'; -import { AccessPoint, Antenna } from '../types'; +import { useAppSelector, useAppDispatch, useAppStore } from '../../lib/hooks'; + +import { AccessPoint, SectorlobeData, Antenna } from '../types'; + +import { initializeActual } from '../../lib/features/actual/actualSlice'; + +import { + initializePlayground, + replacePlayground, + replaceOldPlayground, +} from '../../lib/features/playground/playgroundSlice'; + +import { + initializeCurrent, + changeCurrent, +} from '../../lib/features/currentAntennas/currentAntennasSlice'; + +function IntersectionInfo({ + intersections, + setPanToCoords, +}: { + intersections: SectorlobeData[][][]; + setPanToCoords: (coords: L.LatLngExpression) => void; +}) { + return ( +
+ {intersections.map((intersection, index) => { + return ( +
+ {intersection.map((pair: SectorlobeData[], index: number) => { + const lobe1 = pair[0]; + const lobe2 = pair[1]; + return ( +
+

Frequency {lobe1.frequency} intersection between lobes

+

+ {lobe1.id} and {lobe2.id} +

+ +
+ ); + })} +
+ ); + })} +
+ ); +} function DynamicCircleRadius() { const map = useMap(); @@ -48,12 +105,41 @@ function DynamicCircleRadius() { return null; } +function RecenterMap({ + panToCoords, + setPanToCoords, +}: { + panToCoords: LatLngExpression; + setPanToCoords: (coords: LatLngExpression | null) => void; +}) { + const map = useMap(); + if (panToCoords) { + map.setView(panToCoords, map.getZoom()); + setPanToCoords(null); + } + return null; +} + export default function Map() { const [toggleInfo, setToggleInfo] = useState(false); + const [intersections, setIntersections] = useState([]); + const [panToCoords, setPanToCoords] = useState(null); + const [intersectionToggle, setIntersectionToggle] = useState(false); + const [currentAntenna, setCurrentAntenna] = useState( null ); - const [antennasData, setAntennasData] = useState([]); + + const store = useAppStore(); + const initialized = useRef(false); + + const playgroundData = useAppSelector((state) => state.playground.value); + const actualData = useAppSelector((state) => state.actual.value); + const oldPlaygroundData = useAppSelector((state) => state.playground.old); + + const antennasData = useAppSelector((state) => state.currentAntennas.value); + + const dispatch = useAppDispatch(); useEffect(() => { async function fetchData(path: string, maxRetries = 3, retryDelay = 1000) { @@ -88,15 +174,20 @@ export default function Map() { modelName: ap.modelname, lat: ap.latitude, lon: ap.longitude, - frequency: ap.frequency, - azimuth: ap.azimuth, - antenna_status: ap.antenna_status, - cpu: ap.cpu, - ram: ap.ram, + frequency: ap.frequency || 0, + azimuth: ap.azimuth || 0, + antenna_status: ap.antenna_status || 'N/A', + cpu: ap.cpu || -1, + ram: ap.ram || -1, }) ); - setAntennasData(antennasData); + if (!initialized.current) { + store.dispatch(initializeActual(antennasData)); + store.dispatch(initializePlayground(antennasData)); + store.dispatch(initializeCurrent(antennasData)); + initialized.current = true; + } } } catch (e) { if (e instanceof Error) { @@ -121,10 +212,172 @@ export default function Map() { fetchDataAndSetAntennasData().catch((error) => { console.error(error); }); - }, []); + }, [store]); + + useEffect(() => { + const sectorlobesData: SectorlobeData[] = antennasData.data.map((ap) => { + const center: L.LatLngTuple = [ + parseFloat(ap.lat.trim()), + parseFloat(ap.lon.trim()), + ]; + const heading = ap.azimuth; + const radiusInMeters = 100; + const sectorWidth = 45; + let radius: number = 0; + + if (heading < 45) { + // 0-45 + radius = radiusInMeters; + } else if (heading < 135) { + // 45-135 + radius = radiusInMeters - (radiusInMeters / 100) * 20; + } else if (heading < 225) { + // 135-225 + radius = radiusInMeters; + } else if (heading < 315) { + // 225-315 + radius = radiusInMeters - (radiusInMeters / 100) * 20; + } else if (heading <= 360) { + // 315-360 + radius = radiusInMeters; + } + const numberOfVertices: number = 100; + const earthCircumferenceAtLatitude = + 40008000 * Math.cos((center[0] * Math.PI) / 180); + + const scaleFactor = (radius / earthCircumferenceAtLatitude) * 360; + const sectorVertices: LatLngExpression[] = Array.from( + { length: numberOfVertices + 1 }, + (_, index) => { + const angle: number = + (90 + + heading - + sectorWidth / 2 + + (sectorWidth * index) / numberOfVertices) * + (Math.PI / 180); + + const lat: number = center[0] + scaleFactor * Math.sin(angle); + // const lng: number = center[1] + scaleFactor * Math.cos(angle) * 0.3; + const lng: number = center[1] + scaleFactor * Math.cos(angle); + + return [lat, lng]; + } + ); + sectorVertices.push(center); + return { + id: ap.id, + center, + sectorVertices, + frequency: ap.frequency, + }; + }); + // gather lobes into clusters by frequency + if (sectorlobesData.length > 0) { + const clusters: { [key: string]: SectorlobeData[] } = {}; + const foundIntersections: { [key: string]: SectorlobeData[][] } = {}; + for (const lobe of sectorlobesData) { + const key = lobe.frequency.toString(); + if (!(key in clusters)) { + clusters[key] = []; + } + clusters[key].push(lobe); + } + + // for each cluster, find if any lobes overlap + for (const key in clusters) { + const cluster = clusters[key]; + for (let i = 0; i < cluster.length; i++) { + const lobe1 = cluster[i]; + for (let j = i + 1; j < cluster.length; j++) { + const lobe2 = cluster[j]; + + // Convert the LatLngExpression[] to a Position[] for turf.js + const lobe1Positions: Position[] = lobe1.sectorVertices.map( + (vertex) => { + if (Array.isArray(vertex)) { + return [vertex[1], vertex[0]]; + } else { + return [vertex.lng, vertex.lat]; + } + } + ); + + const lobe2Positions: Position[] = lobe2.sectorVertices.map( + (vertex) => { + if (Array.isArray(vertex)) { + return [vertex[1], vertex[0]]; + } else { + return [vertex.lng, vertex.lat]; + } + } + ); + + const center1: Position = [lobe1.center[1], lobe1.center[0]]; + + const center2: Position = [lobe2.center[1], lobe2.center[0]]; + + lobe1Positions.unshift(center1); + lobe2Positions.unshift(center2); + + const poly1 = polygon([lobe1Positions]); + const poly2 = polygon([lobe2Positions]); + const intersection = intersect({ + type: 'FeatureCollection', + features: [poly1, poly2], + }); + if (intersection) { + if (!(key in foundIntersections)) { + foundIntersections[key] = []; + } + foundIntersections[key].push([lobe1, lobe2]); + } + } + } + } + setIntersections(Object.values(foundIntersections)); + } + }, [antennasData]); + + const getAmountOfIntersections = useMemo(() => { + let amount = 0; + for (let i = 0; i < intersections.length; i++) { + amount += intersections[i].length; + } + return amount; + }, [intersections]); return ( <> +
+ {intersections.length > 0 ? ( +

+ Found {getAmountOfIntersections} intersection + {getAmountOfIntersections > 1 ? 's' : ''} +

+ ) : ( +

No intersections found

+ )} + {intersectionToggle && intersections.length > 0 ? ( + + setPanToCoords(coords) + } + /> + ) : null} + {intersections.length > 0 ? ( + + ) : null} +
{toggleInfo ? ( + {panToCoords ? ( + + ) : null} {/* Call anything you want to add to the map here. */} - + @@ -164,13 +423,62 @@ export default function Map() { currentAntenna={currentAntenna} setCurrentAntenna={setCurrentAntenna} getToggle={toggleInfo} - antennasData={antennasData} changeToggle={() => setToggleInfo(!toggleInfo)} /> +
+
+

Current mode:

+ +
+ {antennasData.mode === 'playground' ? ( +
+ + +
+ ) : null} +
); } diff --git a/app/components/SectorLobe.tsx b/app/components/SectorLobe.tsx index 3586783..e07466b 100644 --- a/app/components/SectorLobe.tsx +++ b/app/components/SectorLobe.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Polygon, Popup } from 'react-leaflet'; import 'leaflet/dist/leaflet.css'; @@ -6,20 +6,76 @@ import { LatLngExpression } from 'leaflet'; import { SectorLobeProps } from '../types'; -export default function SectorLobe({ key, val }: SectorLobeProps) { +import { useAppSelector, useAppDispatch } from '../../lib/hooks'; + +import { updateCurrent } from '@/lib/features/currentAntennas/currentAntennasSlice'; + +export default function SectorLobe({ + key_path, + val, + ap, + freqRange, +}: SectorLobeProps) { + const currentMode = useAppSelector( + (state) => state.currentAntennas.value.mode + ); + const dispatch = useAppDispatch(); + const center: LatLngExpression = [ parseFloat(val.lat.trim()), parseFloat(val.lon.trim()), ]; - const [radiusInMeters, setRadiusInMeters] = useState(100); // Adjust this as needed - const [sectorWidth, setSectorWidth] = useState(45); - const [heading, setHeading] = useState(0); + const radiusInMeters = 100; + const sectorWidth = 45; + + const getColor = useCallback( + (freq: number = ap.frequency) => { + const section = (freqRange[1] - freqRange[0]) / 5; + if (freq >= freqRange[0] && freq < freqRange[0] + section) { + return '#43a047'; + } else if ( + freq >= freqRange[0] + section && + freq < freqRange[0] + section * 2 + ) { + return '#679e20'; + } else if ( + freq >= freqRange[0] + section * 2 && + freq < freqRange[0] + section * 3 + ) { + return '#8c9a00'; + } else if ( + freq >= freqRange[0] + section * 3 && + freq < freqRange[0] + section * 4 + ) { + return '#b29000'; + } else if (freq >= freqRange[0] + section * 4 && freq <= freqRange[1]) { + return '#d98000'; + } + return '#ff6600'; + }, + [ap.frequency, freqRange] + ); + + const [heading, setHeading] = useState(ap.azimuth); + const [freq, setFreq] = useState(ap.frequency); + const [color, setColor] = useState(getColor()); + const [currentAp, setCurrentAp] = useState(ap); // holds previous values in case they do not want to commit - const [tempRadius, setTempRadius] = useState(100); - const [tempSectorWidth, setTempSectorWidth] = useState(45); - const [tempHeading, setTempHeading] = useState(0); + const [tempHeading, setTempHeading] = useState(ap.azimuth); + const [tempFreq, setTempFreq] = useState(ap.frequency); + + // Won't update if the ap changes without useEffect + useEffect(() => { + setHeading(ap.azimuth); + setFreq(ap.frequency); + setCurrentAp(ap); + setColor(getColor(ap.frequency)); + setTempHeading(ap.azimuth); + setTempFreq(ap.frequency); + }, [ap, getColor]); + let radius: number = 0; const radiusRange: number[][] = [ [0, 45], [45, 135], @@ -27,7 +83,6 @@ export default function SectorLobe({ key, val }: SectorLobeProps) { [225, 315], [315, 360], ]; - let radius: number = 0; if (heading < radiusRange[0][1]) { // 0-45 radius = radiusInMeters; @@ -72,12 +127,10 @@ export default function SectorLobe({ key, val }: SectorLobeProps) { ); sectorVertices.push(center); - function handleChangeRadius(e: React.ChangeEvent) { - setTempRadius(Number(e.target.value)); - } - - function handleChangeSectorWidth(e: React.ChangeEvent) { - setTempSectorWidth(Number(e.target.value)); + function handleChangeFreq(e: React.ChangeEvent) { + const currFreq = Number(e.target.value); + setTempFreq(currFreq); + setColor(getColor(currFreq)); } function handleChangeHeading(e: React.ChangeEvent) { @@ -85,7 +138,7 @@ export default function SectorLobe({ key, val }: SectorLobeProps) { if (numChange < 0) { numChange = 0; } else if (numChange >= 360) { - numChange = 359; + numChange = 0; } setTempHeading(numChange); } @@ -94,70 +147,75 @@ export default function SectorLobe({ key, val }: SectorLobeProps) { e: React.FormEvent | React.MouseEvent ) { e.preventDefault(); - setRadiusInMeters(tempRadius); - setSectorWidth(tempSectorWidth); setHeading(tempHeading); + setFreq(tempFreq); + const newAp = { ...currentAp }; + newAp.azimuth = tempHeading; + newAp.frequency = tempFreq; + + setCurrentAp(newAp); + if (currentMode === 'playground') { + dispatch(updateCurrent(newAp)); + } } function handleCancel() { - setTempRadius(radiusInMeters); - setTempSectorWidth(sectorWidth); setTempHeading(heading); + setTempFreq(freq); } return (
handleCommit(e)} > -

Change Sector Lobe

-
- - handleChangeRadius(e)} - />{' '} - m -
+

Change Sector Lobe of {ap.id}

- - handleChangeSectorWidth(e)} - />{' '} - degrees + + {currentMode === 'playground' ? ( + handleChangeFreq(e)} + /> + ) : ( + `${freq} ` + )} + Hz
- - handleChangeHeading(e)} - />{' '} - degrees + + {currentMode === 'playground' ? ( + handleChangeHeading(e)} + /> + ) : ( + `${heading}` + )} + °