>;
17 | loading: boolean;
18 | }
19 |
20 | const DateSelect = (props: SidebarProps) => {
21 | const handleChangeTimeFrom = (newValue: dayjs.Dayjs | null) => {
22 | if (newValue) {
23 | props.setTimeFrom(newValue.toDate());
24 | }
25 | };
26 |
27 | const handleChangeTimeTo = (newValue: dayjs.Dayjs | null) => {
28 | if (newValue) {
29 | props.setTimeTo(newValue.toDate());
30 | }
31 | };
32 |
33 | return (
34 |
35 | Filter Date Time
36 |
37 |
38 |
44 |
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default DateSelect;
57 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import path from 'path'
4 |
5 | export default defineConfig({
6 | plugins: [react()],
7 | base: "./",
8 | resolve: {
9 | alias: {
10 | '@': path.resolve(__dirname, './src'),
11 | },
12 | },
13 | server: {
14 | host: true,
15 | port: 3002,
16 | cors: true,
17 | proxy: {
18 | '/api': {
19 | target: 'http://localhost:3000',
20 | changeOrigin: true,
21 | secure: false,
22 | rewrite: (path) => path.replace(/^\/api/, ''),
23 | configure: (proxy, options) => {
24 | proxy.on('error', (err, req, res) => {
25 | console.log('proxy error', err);
26 | });
27 | proxy.on('proxyReq', (proxyReq, req, res) => {
28 | console.log('Sending Request to the Target:', req.method, req.url);
29 | });
30 | proxy.on('proxyRes', (proxyRes, req, res) => {
31 | console.log('Received Response from the Target:', proxyRes.statusCode);
32 | });
33 | },
34 | },
35 | '/secure': {
36 | target: 'http://localhost:3000',
37 | changeOrigin: true,
38 | secure: false,
39 | rewrite: (path) => path.replace(/^\/secure/, ''),
40 | configure: (proxy, options) => {
41 | proxy.on('error', (err, req, res) => {
42 | console.log('proxy error', err);
43 | });
44 | proxy.on('proxyReq', (proxyReq, req, res) => {
45 | console.log('Sending Request to the Target:', req.method, req.url);
46 | });
47 | proxy.on('proxyRes', (proxyRes, req, res) => {
48 | console.log('Received Response from the Target:', proxyRes.statusCode);
49 | });
50 | },
51 | }
52 | }
53 | },
54 | publicDir: 'public',
55 | build: {
56 | outDir: "build"
57 | }
58 | })
--------------------------------------------------------------------------------
/src/ListItems.tsx:
--------------------------------------------------------------------------------
1 | import ListItem from '@mui/material/ListItem';
2 | import ListItemIcon from '@mui/material/ListItemIcon';
3 | import ListItemText from '@mui/material/ListItemText';
4 | import ListSubheader from '@mui/material/ListSubheader';
5 | import EditLocationAltIcon from '@mui/icons-material/EditLocationAlt';
6 | import ManageAccountsIcon from '@mui/icons-material/ManageAccounts';
7 | import SyncIcon from '@mui/icons-material/Sync';
8 | import HomeIcon from '@mui/icons-material/Home';
9 | import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
10 | import { ListItemButton } from '@mui/material';
11 |
12 | export const mainListItems = (
13 |
14 | Admin Panel
15 | window.open('/admin/users', '_self')}>
16 |
17 |
18 |
19 |
20 |
21 | window.open('/admin/edit-site', '_self')}>
22 |
23 |
24 |
25 |
26 |
27 | window.open('/admin/edit-data', '_self')}>
28 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 |
36 | export const secondaryListItems = (
37 |
38 | User Panel
39 | window.open('/')}>
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 |
48 | export const homeListItems = (
49 |
50 |
window.open('login')}>
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 |
--------------------------------------------------------------------------------
/src/vis/MapSelectionRadio.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Radio from '@mui/material/Radio';
3 | import Typography from '@mui/material/Typography';
4 | import RadioGroup from '@mui/material/RadioGroup';
5 | import Box from '@mui/material/Box';
6 | import FormControlLabel from '@mui/material/FormControlLabel';
7 | import FormControl from '@mui/material/FormControl';
8 | import '@fontsource/roboto';
9 |
10 | const MAP_TYPE_INDEX = {
11 | dbm: 1,
12 | ping: 1,
13 | upload_speed: 1,
14 | download_speed: 1,
15 | } as const;
16 | export type MapType = keyof typeof MAP_TYPE_INDEX;
17 |
18 | function isMapType(m: any): m is MapType {
19 | return m in MAP_TYPE_INDEX;
20 | }
21 |
22 | interface MapSelectionRadioProps {
23 | mapType: MapType;
24 | setMapType: React.Dispatch>;
25 | loading: boolean;
26 | }
27 |
28 | export default function MapSelectionRadio(props: MapSelectionRadioProps) {
29 | type InputEvent = React.ChangeEvent;
30 | const handleChange = (event: InputEvent) => {
31 | const _mapType = event.target.value;
32 | if (!isMapType(_mapType)) {
33 | throw new Error('Invalid map type selection: ' + _mapType);
34 | }
35 | props.setMapType(_mapType);
36 | };
37 |
38 | return (
39 |
40 |
41 | Map Type
42 |
48 | }
51 | label='Signal Strength'
52 | />
53 | }
56 | label='Upload Speed'
57 | />
58 | }
61 | label='Download Speed'
62 | />
63 | } label='Ping' />
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cnn-coverage-vis",
3 | "homepage": "https://seattlecommunitynetwork.org/ccn-coverage-vis/",
4 | "version": "2.1.0",
5 | "private": true,
6 | "type": "module",
7 | "dependencies": {
8 | "@emotion/react": "^11.14.0",
9 | "@emotion/styled": "^11.14.0",
10 | "@fontsource/roboto": "^5.2.5",
11 | "@mui/icons-material": "^6.4.7",
12 | "@mui/material": "^6.4.7",
13 | "@mui/x-date-pickers": "^7.27.3",
14 | "add": "^2.0.6",
15 | "arquero": "^8.0.1",
16 | "cors": "^2.8.5",
17 | "d3": "^7.8.5",
18 | "dayjs": "^1.11.13",
19 | "jsonwebtoken": "^9.0.2",
20 | "leaflet": "^1.9.4",
21 | "leaflet-geosearch": "^4.2.0",
22 | "openapi-fetch": "^0.13.5",
23 | "qrcode.react": "^4.2.0",
24 | "react": "^19.0.0",
25 | "react-dom": "^19.0.0",
26 | "react-router": "^7.3.0",
27 | "react-router-dom": "^7.3.0",
28 | "react-select": "^5.10.1",
29 | "web-vitals": "^4.2.4"
30 | },
31 | "scripts": {
32 | "start": "vite",
33 | "dev": "vite --host",
34 | "build": "tsc && vite build",
35 | "preview": "vite preview",
36 | "format": "prettier --write --config ./prettierrc.json src/**/*.{ts,tsx}",
37 | "format-check": "prettier --check --config ./prettierrc.json src/**/*.{ts,tsx}",
38 | "release-dry-run": "semantic-release --dry-run"
39 | },
40 | "browserslist": {
41 | "production": [
42 | ">0.2%",
43 | "not dead",
44 | "not op_mini all"
45 | ],
46 | "development": [
47 | "last 1 chrome version",
48 | "last 1 firefox version",
49 | "last 1 safari version"
50 | ]
51 | },
52 | "devDependencies": {
53 | "@octokit/rest": "^21.1.1",
54 | "@semantic-release/changelog": "^6.0.3",
55 | "@semantic-release/git": "^10.0.1",
56 | "@semantic-release/github": "^11.0.2",
57 | "@types/cors": "^2.8.17",
58 | "@types/d3": "^7.4.3",
59 | "@types/jsonwebtoken": "^9.0.5",
60 | "@types/leaflet": "^1.9.8",
61 | "@types/node": "^22.13.10",
62 | "@types/qrcode.react": "^1.0.5",
63 | "@types/react": "^19.0.10",
64 | "@types/react-dom": "^19.0.4",
65 | "@vitejs/plugin-react": "^4.3.4",
66 | "canvas": "^3.1.0",
67 | "js-yaml": "^4.1.0",
68 | "prettier": "^3.2.5",
69 | "semantic-release": "^24.2.4",
70 | "typescript": "^5.8.2",
71 | "vite": "^6.2.2"
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/vis/DisplaySelection.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Typography from '@mui/material/Typography';
3 | import Box from '@mui/material/Box';
4 | import FormControlLabel from '@mui/material/FormControlLabel';
5 | import FormControl from '@mui/material/FormControl';
6 | import FormGroup from '@mui/material/FormGroup';
7 | import Checkbox from '@mui/material/Checkbox';
8 | import '@fontsource/roboto';
9 |
10 | interface DisplayOptionsProps {
11 | displayOptions: DisplayOption[];
12 | setDisplayOptions: React.Dispatch>;
13 | loading: boolean;
14 | }
15 |
16 | export const solveDisplayOptions = (
17 | displayOptions: DisplayOption[],
18 | name: string,
19 | value: boolean,
20 | ) => {
21 | const newOptions: DisplayOption[] = [];
22 | for (let option of displayOptions) {
23 | if (option.name === name) {
24 | option.checked = value;
25 | }
26 | newOptions.push(option);
27 | }
28 | return newOptions;
29 | };
30 |
31 | export default function DisplaySelection(props: DisplayOptionsProps) {
32 | type InputEvent = React.ChangeEvent;
33 | const handleChange = (event: InputEvent) => {
34 | const checked = event.target.checked;
35 | const name = event.target.name;
36 | const _displayOptions = [...props.displayOptions];
37 |
38 | props.setDisplayOptions(
39 | _displayOptions.map(option => {
40 | if (option.name === name) {
41 | option.checked = checked;
42 | }
43 | return option;
44 | }),
45 | );
46 | };
47 |
48 | return (
49 |
50 |
51 | Display Options
52 |
53 | {props.displayOptions.map((option: DisplayOption) => (
54 |
63 | }
64 | label={option.label}
65 | />
66 | ))}
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/scripts/generate-certs.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Set variables
4 | PRIMARY_DOMAIN="ccn-coverage-vis"
5 | CERT_DIR="/certs" # Use absolute path
6 | DAYS_VALID=365
7 |
8 | # Add all domains that might be used to access your service
9 | DOMAINS=(
10 | "$PRIMARY_DOMAIN"
11 | "localhost"
12 | "127.0.0.1"
13 | )
14 |
15 | # Create directory for certificates if it doesn't exist
16 | mkdir -p $CERT_DIR
17 |
18 | echo "Generating self-signed certificates..."
19 |
20 | # Generate private key
21 | openssl genrsa -out $CERT_DIR/private-key.pem 2048
22 |
23 | # Create config file for SAN support
24 | cat > $CERT_DIR/openssl.cnf << EOF
25 | [req]
26 | distinguished_name = req_distinguished_name
27 | req_extensions = v3_req
28 | prompt = no
29 |
30 | [req_distinguished_name]
31 | CN = $PRIMARY_DOMAIN
32 |
33 | [v3_req]
34 | basicConstraints = CA:FALSE
35 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment
36 | subjectAltName = @alt_names
37 |
38 | [alt_names]
39 | EOF
40 |
41 | # Add all domains to the config file
42 | for i in "${!DOMAINS[@]}"; do
43 | echo "DNS.$((i+1)) = ${DOMAINS[$i]}" >> $CERT_DIR/openssl.cnf
44 | done
45 |
46 | # Generate a CSR with the config
47 | openssl req -new -key $CERT_DIR/private-key.pem -out $CERT_DIR/csr.pem -config $CERT_DIR/openssl.cnf
48 |
49 | # Generate the self-signed certificate
50 | openssl x509 -req -days $DAYS_VALID -in $CERT_DIR/csr.pem -signkey $CERT_DIR/private-key.pem -out $CERT_DIR/certificate.pem -extensions v3_req -extfile $CERT_DIR/openssl.cnf
51 |
52 | # Create a full chain file
53 | cat $CERT_DIR/certificate.pem > $CERT_DIR/fullchain.pem
54 |
55 | # Set proper permissions (readable by all)
56 | chmod 644 $CERT_DIR/private-key.pem
57 | chmod 644 $CERT_DIR/certificate.pem
58 | chmod 644 $CERT_DIR/fullchain.pem
59 |
60 | # Try to generate PKCS12 file but don't fail if it doesn't work
61 | openssl pkcs12 -export -out $CERT_DIR/certificate.pfx -inkey $CERT_DIR/private-key.pem -in $CERT_DIR/certificate.pem -passout pass: || echo "PKCS12 export failed, but continuing"
62 |
63 | # Verify file creation and permissions
64 | echo "Certificates generated successfully in $CERT_DIR directory!"
65 | echo "Files generated with permissions:"
66 | ls -la $CERT_DIR/
67 |
68 | # Verify certificate content
69 | echo "Verifying certificate:"
70 | openssl x509 -in $CERT_DIR/certificate.pem -text -noout | head -n 15
71 |
72 | # Verify private key
73 | echo "Verifying private key:"
74 | openssl rsa -in $CERT_DIR/private-key.pem -check -noout || echo "Private key verification failed"
--------------------------------------------------------------------------------
/src/admin/ViewQRCode.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Button from '@mui/material/Button';
3 | import Dialog from '@mui/material/Dialog';
4 | import DialogActions from '@mui/material/DialogActions';
5 | import DialogContent from '@mui/material/DialogContent';
6 | import DialogContentText from '@mui/material/DialogContentText';
7 | import DialogTitle from '@mui/material/DialogTitle';
8 | import TextField from '@mui/material/TextField';
9 | import Slider from '@mui/material/Slider';
10 | import QrCodeIcon from '@mui/icons-material/QrCode';
11 | import { QRCodeCanvas } from 'qrcode.react';
12 |
13 | interface ViewQRCodeProp {
14 | identity: string;
15 | qrCode: string;
16 | }
17 |
18 | export default function ViewQRCode(props: ViewQRCodeProp) {
19 | const [open, setOpen] = React.useState(false);
20 | const [size, setSize] = React.useState(512);
21 |
22 | const handleClickOpen = () => {
23 | setOpen(true);
24 | };
25 |
26 | const handleClose = () => {
27 | setOpen(false);
28 | };
29 |
30 | const handleChange = (event: Event, newSize: number | number[]) => {
31 | setSize(newSize as number);
32 | };
33 |
34 | return (
35 |
36 | }
40 | onClick={handleClickOpen}
41 | >
42 | Show
43 |
44 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
18 |
19 |
23 |
24 | Seattle Community Network Coverage Map
25 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/configs/local-nginx.conf:
--------------------------------------------------------------------------------
1 | # HTTP server - redirects to HTTPS
2 | server {
3 | listen 80;
4 | server_name localhost;
5 |
6 | # Redirect all HTTP requests to HTTPS
7 | return 301 https://$host$request_uri;
8 | }
9 |
10 | # HTTPS server
11 | server {
12 | listen 443 ssl;
13 | server_name localhost;
14 |
15 | # SSL certificate configuration
16 | ssl_certificate /etc/nginx/ssl/certs/certificate.pem;
17 | ssl_certificate_key /etc/nginx/ssl/certs/private-key.pem;
18 |
19 | # SSL protocols and ciphers for improved security
20 | ssl_protocols TLSv1.2 TLSv1.3;
21 | ssl_prefer_server_ciphers on;
22 | ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
23 | ssl_session_timeout 1d;
24 | ssl_session_cache shared:SSL:50m;
25 |
26 | # HSTS (HTTP Strict Transport Security)
27 | add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
28 |
29 | # Compression settings for better performance
30 | gzip on;
31 | gzip_vary on;
32 | gzip_min_length 10240;
33 | gzip_proxied expired no-cache no-store private auth;
34 | gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
35 | gzip_disable "MSIE [1-6]\.";
36 |
37 | # Root directory for the site
38 | root /usr/share/nginx/html;
39 |
40 | # API and secure endpoints
41 | location ~ ^/(api|secure)/ {
42 | proxy_pass http://api:3000;
43 | proxy_http_version 1.1;
44 | proxy_set_header Upgrade $http_upgrade;
45 | proxy_set_header Connection 'upgrade';
46 | proxy_set_header Host $host;
47 | proxy_set_header X-Real-IP $remote_addr;
48 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
49 | proxy_set_header X-Forwarded-Proto $scheme;
50 | proxy_cache_bypass $http_upgrade;
51 |
52 | # Timeout settings
53 | proxy_connect_timeout 60s;
54 | proxy_send_timeout 60s;
55 | proxy_read_timeout 60s;
56 | }
57 |
58 | # Handle requests to /admin/assets/ - KEY FIX HERE
59 | location ^~ /admin/assets/ {
60 | # Rewrite requests from /admin/assets/ to /assets/
61 | rewrite ^/admin/assets/(.*) /assets/$1 last;
62 | }
63 |
64 | # Static assets caching
65 | location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
66 | expires 1y;
67 | add_header Cache-Control "public, max-age=31536000, immutable";
68 | try_files $uri =404;
69 | }
70 |
71 | # Handle all routes for the SPA
72 | location / {
73 | index index.html;
74 | try_files $uri $uri/ /index.html;
75 | }
76 |
77 | # Error pages
78 | error_page 404 /index.html;
79 | error_page 500 502 503 504 /50x.html;
80 | location = /50x.html {
81 | try_files $uri =404;
82 | }
83 | }
--------------------------------------------------------------------------------
/src/leaflet-component/site-marker.ts:
--------------------------------------------------------------------------------
1 | import * as L from 'leaflet';
2 | import { UNITS, MULTIPLIERS } from '../utils/measurementMapUtils';
3 | import round2 from '../utils/round-2';
4 |
5 | const statusColor: Map = new Map([
6 | ['active', 'green'],
7 | ['confirmed', 'yellow'],
8 | ['in-conversation', 'red'],
9 | ]);
10 |
11 | export function isSiteArray(sites: any[]): sites is Site[] {
12 | return sites.every(isSite);
13 | }
14 |
15 | export function isMarkerArray(marker: any[]): marker is Marker[] {
16 | return marker.every(isMarker);
17 | }
18 |
19 | export function isSite(prop: any): prop is Site {
20 | return (
21 | typeof prop?.name === 'string' ||
22 | typeof prop?.latitude === 'number' ||
23 | typeof prop?.longitude === 'number' ||
24 | typeof prop?.address === 'string' ||
25 | prop?.status in statusColor
26 | );
27 | }
28 |
29 | export function isMarker(prop: any): prop is Marker {
30 | return (
31 | typeof prop?.latitude === 'number' ||
32 | typeof prop?.longitude === 'number' ||
33 | typeof prop?.device_id === 'string' ||
34 | typeof prop?.cell_id === 'string' ||
35 | typeof prop?.dbm === 'number' ||
36 | typeof prop?.upload_speed === 'number' ||
37 | typeof prop?.download_speed === 'number' ||
38 | typeof prop?.ping === 'number' ||
39 | typeof prop?.mid === 'string'
40 | );
41 | }
42 |
43 | export function siteMarker(
44 | site: Site,
45 | summary: {
46 | dbm: number;
47 | ping: number;
48 | upload_speed: number;
49 | download_speed: number;
50 | },
51 | map: L.Map,
52 | ) {
53 | return L.marker([site.latitude, site.longitude])
54 | .bindTooltip(
55 | `${site.name} [${site.status}]
${site.address}
58 | signal strength: ${round2(summary?.dbm * MULTIPLIERS.dbm)} ${UNITS.dbm}
59 | ping: ${round2(summary?.ping * MULTIPLIERS.ping)} ${UNITS.ping}
60 | upload speed: ${round2(summary?.upload_speed * MULTIPLIERS.upload_speed)} ${
61 | UNITS.upload_speed
62 | }
63 | download speed: ${round2(
64 | summary?.download_speed * MULTIPLIERS.download_speed,
65 | )} ${UNITS.download_speed}`,
66 | )
67 | .on('click', function (e: any) {
68 | map.setView(e.latlng, 13);
69 | });
70 | }
71 |
72 | export function siteSmallMarker(m: Marker) {
73 | return L.marker([m.latitude, m.longitude]).bindTooltip(
74 | `${m.site}
75 |
76 | ${m.device_id}
77 |
${m.latitude}, ${m.longitude}
78 | signal strength: ${
79 | m.dbm === undefined ? 'N/A' : round2(m.dbm * MULTIPLIERS.dbm)
80 | } ${UNITS.dbm}
81 | ping: ${round2(m.ping * MULTIPLIERS.ping)} ${UNITS.ping}
82 | upload speed: ${round2(m.upload_speed * MULTIPLIERS.upload_speed)} ${
83 | UNITS.upload_speed
84 | }
85 | download speed: ${round2(m.download_speed * MULTIPLIERS.download_speed)} ${
86 | UNITS.download_speed
87 | }`,
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/src/vis/MapLegend.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import * as d3 from 'd3';
3 | import { createCanvas } from 'canvas';
4 |
5 | const tickSize = 6;
6 | const height = 150;
7 | const marginTop = 40;
8 | const marginRight = 15;
9 | const marginBottom = 0;
10 | const marginLeft = 0;
11 | const ticks = height / 64;
12 |
13 | function ramp(color: (t: number) => string, n = 256) {
14 | const canvas = createCanvas(1, n);
15 | const context = canvas.getContext('2d');
16 |
17 | for (let i = 0; i < n; ++i) {
18 | context.fillStyle = color(i / (n - 1));
19 | context.fillRect(0, i, 1, 1);
20 | }
21 |
22 | return canvas;
23 | }
24 |
25 | interface MapProps {
26 | colorDomain: number[] | undefined;
27 | title: string;
28 | width: number;
29 | }
30 |
31 | const MapLegend = ({ colorDomain, title, width }: MapProps) => {
32 | const _svg = useRef(null);
33 |
34 | if (colorDomain && _svg.current) {
35 | const color = d3.scaleSequential(colorDomain, d3.interpolateViridis);
36 | const tickFormat = d3.format('.2f');
37 |
38 | const svg = d3
39 | .select(_svg.current)
40 | .attr('width', width)
41 | .attr('height', height)
42 | .attr('viewBox', [0, 0, width, height].join(' '))
43 | .style('overflow', 'visible')
44 | .style('display', 'block');
45 |
46 | svg.selectAll('*').remove();
47 |
48 | let tickAdjust = (g: d3.Selection) =>
49 | g.selectAll('.tick line').attr('x1', width - marginRight - marginLeft);
50 | let x = Object.assign(
51 | color
52 | .copy()
53 | .interpolator(d3.interpolateRound(marginTop, height - marginBottom)),
54 | {
55 | range() {
56 | return [height - marginBottom, marginTop];
57 | },
58 | },
59 | );
60 |
61 | svg
62 | .append('image')
63 | .attr('x', marginLeft)
64 | .attr('y', marginTop)
65 | .attr('width', width - marginLeft - marginRight)
66 | .attr('height', height - marginTop - marginBottom)
67 | .attr('preserveAspectRatio', 'none')
68 | .attr(
69 | 'xlink:href',
70 | ramp(
71 | color.interpolator(),
72 | height - marginTop - marginBottom,
73 | ).toDataURL(),
74 | );
75 |
76 | const n = Math.round(ticks + 1);
77 | const tickValues = d3
78 | .range(n)
79 | .map(i => d3.quantile(color.domain(), i / (n - 1)) ?? NaN);
80 |
81 | svg
82 | .append('g')
83 | .attr('transform', `translate(${marginLeft},${0})`)
84 | .call(
85 | d3
86 | .axisLeft(x)
87 | .ticks(ticks, typeof tickFormat === 'string' ? tickFormat : undefined)
88 | // .tickFormat(typeof tickFormat === 'function' ? tickFormat : undefined)
89 | .tickSize(tickSize)
90 | .tickValues(tickValues),
91 | )
92 | .call(tickAdjust)
93 | .call(g => g.select('.domain').remove())
94 | .call(g =>
95 | g
96 | .append('text')
97 | .attr('y', marginTop - 12)
98 | .attr('x', width - marginRight - marginLeft - 2)
99 | .attr('fill', 'currentColor')
100 | .attr('text-anchor', 'begin')
101 | .attr('font-weight', 'bold')
102 | .attr('class', 'title')
103 | .text(title),
104 | );
105 | }
106 |
107 | return ;
108 | };
109 |
110 | export default MapLegend;
111 |
--------------------------------------------------------------------------------
/src/admin/NewUserDialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Button from '@mui/material/Button';
3 | import TextField from '@mui/material/TextField';
4 | import Dialog from '@mui/material/Dialog';
5 | import DialogActions from '@mui/material/DialogActions';
6 | import DialogContent from '@mui/material/DialogContent';
7 | import DialogContentText from '@mui/material/DialogContentText';
8 | import DialogTitle from '@mui/material/DialogTitle';
9 | import Stack from '@mui/material/Stack';
10 | import AddIcon from '@mui/icons-material/Add';
11 | import { apiClient } from '@/utils/fetch';
12 |
13 | interface NewUserDialogProp {
14 | setCalled: React.Dispatch>;
15 | }
16 | export default function NewUserDialog(props: NewUserDialogProp) {
17 | const [open, setOpen] = React.useState(false);
18 | const [email, setEmail] = React.useState('');
19 | const [firstName, setFirstName] = React.useState('');
20 | const [lastName, setLastName] = React.useState('');
21 |
22 | const handleClickOpen = () => {
23 | setOpen(true);
24 | };
25 |
26 | const handleClose = () => {
27 | setOpen(false);
28 | };
29 |
30 | const handleSubmit = () => {
31 | apiClient
32 | .POST('/secure/new-user', {
33 | body: {
34 | firstName: firstName,
35 | lastName: lastName,
36 | email: email,
37 | },
38 | })
39 | .then(res => {
40 | const { error } = res;
41 | if (error) {
42 | console.log(`Unable to create user: ${error}`);
43 | setOpen(true);
44 | return;
45 | }
46 |
47 | props.setCalled(false);
48 | setOpen(false);
49 | })
50 | .catch(err => {
51 | console.log(`Unable to create user: ${err}`);
52 | setOpen(true);
53 | });
54 | };
55 |
56 | return (
57 |
58 |
59 | }
63 | onClick={handleClickOpen}
64 | >
65 | New user
66 |
67 |
68 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ccn-coverage-vis
2 |
3 | Visualizations of coverage and performance analysis for Community Cellular Networks.
4 |
5 | Now hosted on https://coverage.seattlecommunitynetwork.org/
6 |
7 |
8 | ## Initial Setup
9 | To install this service, the fist time, you will need to:
10 |
11 | 1. Required tools and versions:
12 | 1. Install `node` and `npm` according to the directions at https://nodejs.org/en/download/package-manager
13 | 2. `make` and `docker` are used for local development
14 | 2. Clone the service: `https://github.com/Local-Connectivity-Lab/ccn-coverage-vis`
15 | 3. Configure:
16 | 1. `cd cd ccn-coverage-vis`
17 | 1. Edit `src/utils/config.ts` and set the correct URL for your API host (if you're testing or you're deploying to a new URL).
18 |
19 | ## Development
20 | Avoid committing your change directly to the `main` branch. Check out your own private branch for your development and submit a pull request when the feature/bug fix/cleanup is ready for review. Follow the [Angular Commit Message Conventions](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md) for your commit message. Versions will be managed based on those commit messages.
21 |
22 | ## Troubleshooting & Recovery
23 | When a problem occurs, there are several checks to determine where the failure is:
24 | 1. Check HTTP errors in the browser
25 | 1. Login to the coverage-host
26 | 2. Confirm ccn-coverage-vis is operating as expected
27 | 3. Confirm nginx is operating as expected
28 |
29 | ### Checking HTTP errors in the browser
30 | First, open your browser and go to: https://coverage.seattlecommunitynetwork.org/
31 |
32 | Is it working?
33 |
34 | If not, open up the browser **Web Developer Tools**, usually under the menu Tools > Developer Tools > Web Developer Tools.
35 |
36 | With this panel open at the bottom of the screen select the **Network** tab and refresh the browser page.
37 |
38 | Look in the first column, Status:
39 | * `200`: OK, everything is good.
40 | * `502`: Error with the backend services (behind nginx)
41 | * `500` errors: problem with nxginx. Look in `/var/log/nginx/error.log` for details.
42 | * `400` errors: problem with the service. Check the service logs and nginx logs.
43 | * Timeout or unreachable error: Something is broken in the network between your web browser and the coverage-vis host.
44 |
45 |
46 | ### Checking nginx
47 | If there appear problems with nginx, then check that the
48 |
49 | Check service operation:
50 | ```
51 | systemctl status nginx
52 | ```
53 |
54 | Check nginx logs:
55 | ```
56 | sudo tail /var/log/nginx/error.log
57 | ```
58 |
59 | Sources of errors might include nginx configuration in `/etc/nginx/conf.d/01-ccn-coverage.conf`
60 |
61 | If you need to restart nginx, use:
62 | ```
63 | sudo systemctl restart nginx
64 | ```
65 |
66 | ### Clean Recovery
67 | If nothing else works, the last option is a clean reinstall of the service. The process is:
68 | * Remove the `ccn-coverage-vis` directory.
69 | * Re-install as per **Initial Setup**.
70 |
71 |
72 | ## Testing
73 |
74 | We provide a docker compose environment for local testing. Run `docker compose up -d`, and the web server will be running on your local host at port `443`.
75 |
76 |
77 | # Issues
78 |
79 | - Chart doesn't show tooltips.
80 |
81 | # TODOs
82 |
83 | - Toggle graph view off results in no toggle-on button
84 | - Make the chart more informative
85 | - Hover on a line should show the exact data and which sites are they from
86 | - Admin Panel
87 | - Edit Button
88 | - Toggle Active
89 | - Better compatibility with local development
90 |
91 | ## Contributing
92 | Any contribution and pull requests are welcome! However, before you plan to implement some features or try to fix an uncertain issue, it is recommended to open a discussion first. You can also join our [Discord channel](https://discord.com/invite/gn4DKF83bP), or visit our [website](https://seattlecommunitynetwork.org/).
93 |
94 | ## License
95 | ccn-coverage-vis is released under Apache License. See [LICENSE](/LICENSE) for more details.
--------------------------------------------------------------------------------
/src/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { styled, createTheme, ThemeProvider } from '@mui/material/styles';
3 | import CssBaseline from '@mui/material/CssBaseline';
4 | import MuiDrawer from '@mui/material/Drawer';
5 | import Box from '@mui/material/Box';
6 | import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar';
7 | import Toolbar from '@mui/material/Toolbar';
8 | import List from '@mui/material/List';
9 | import Typography from '@mui/material/Typography';
10 | import Divider from '@mui/material/Divider';
11 | import IconButton from '@mui/material/IconButton';
12 | import Badge from '@mui/material/Badge';
13 | import Container from '@mui/material/Container';
14 | import MenuIcon from '@mui/icons-material/Menu';
15 | import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
16 | import NotificationsIcon from '@mui/icons-material/Notifications';
17 | import { mainListItems, secondaryListItems } from './ListItems';
18 | import Footer from './Footer';
19 |
20 | const drawerWidth: number = 240;
21 |
22 | interface AppBarProps extends MuiAppBarProps {
23 | open?: boolean;
24 | }
25 |
26 | const AppBar = styled(MuiAppBar, {
27 | shouldForwardProp: prop => prop !== 'open',
28 | })(({ theme, open }) => ({
29 | zIndex: theme.zIndex.drawer + 1,
30 | transition: theme.transitions.create(['width', 'margin'], {
31 | easing: theme.transitions.easing.sharp,
32 | duration: theme.transitions.duration.leavingScreen,
33 | }),
34 | ...(open && {
35 | marginLeft: drawerWidth,
36 | width: `calc(100% - ${drawerWidth}px)`,
37 | transition: theme.transitions.create(['width', 'margin'], {
38 | easing: theme.transitions.easing.sharp,
39 | duration: theme.transitions.duration.enteringScreen,
40 | }),
41 | }),
42 | }));
43 |
44 | const Drawer = styled(MuiDrawer, {
45 | shouldForwardProp: prop => prop !== 'open',
46 | })(({ theme, open }) => ({
47 | '& .MuiDrawer-paper': {
48 | position: 'relative',
49 | whiteSpace: 'nowrap',
50 | width: drawerWidth,
51 | transition: theme.transitions.create('width', {
52 | easing: theme.transitions.easing.sharp,
53 | duration: theme.transitions.duration.enteringScreen,
54 | }),
55 | boxSizing: 'border-box',
56 | ...(!open && {
57 | overflowX: 'hidden',
58 | transition: theme.transitions.create('width', {
59 | easing: theme.transitions.easing.sharp,
60 | duration: theme.transitions.duration.leavingScreen,
61 | }),
62 | width: theme.spacing(7),
63 | [theme.breakpoints.up('sm')]: {
64 | width: theme.spacing(9),
65 | },
66 | }),
67 | },
68 | }));
69 |
70 | const mdTheme = createTheme();
71 |
72 | export default function Navbar() {
73 | const [open, setOpen] = React.useState(true);
74 | const toggleDrawer = () => {
75 | setOpen(!open);
76 | };
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
88 |
98 |
99 |
100 |
107 | Dashboard
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
125 |
126 |
127 |
128 |
129 |
130 | {mainListItems}
131 |
132 | {secondaryListItems}
133 |
134 |
135 |
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/src/admin/Login.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Button from '@mui/material/Button';
3 | import CssBaseline from '@mui/material/CssBaseline';
4 | import TextField from '@mui/material/TextField';
5 | import FormControlLabel from '@mui/material/FormControlLabel';
6 | import Checkbox from '@mui/material/Checkbox';
7 | import Box from '@mui/material/Box';
8 | import Snackbar from '@mui/material/Snackbar';
9 | import Alert from '@mui/material/Alert';
10 | import Typography from '@mui/material/Typography';
11 | import Container from '@mui/material/Container';
12 | import Footer from '../Footer';
13 | import { createTheme, ThemeProvider } from '@mui/material/styles';
14 | import { apiClient } from '@/utils/fetch';
15 | const theme = createTheme();
16 |
17 | export default function Login() {
18 | const [open, setOpen] = React.useState(false);
19 | const [errorMessage, setErrorMessage] = React.useState('');
20 | const handleClose = (
21 | event?: React.SyntheticEvent | Event,
22 | reason?: string,
23 | ) => {
24 | if (reason === 'clickaway') {
25 | return;
26 | }
27 |
28 | setOpen(false);
29 | };
30 | const handleSubmit = (event: React.FormEvent) => {
31 | event.preventDefault();
32 | const data = new FormData(event.currentTarget);
33 | // eslint-disable-next-line no-console
34 |
35 | if (!data.has('username')) {
36 | return;
37 | }
38 |
39 | if (!data.has('password')) {
40 | return;
41 | }
42 |
43 | const username = data.get('username')?.toString() as string;
44 | const password = data.get('password')?.toString() as string;
45 |
46 | apiClient
47 | .POST('/secure/login', {
48 | body: {
49 | username: username,
50 | password: password,
51 | },
52 | })
53 | .then(res => {
54 | const { data, error } = res;
55 | if (!data || error) {
56 | console.log(`Unable to login: ${error.error}`);
57 | setErrorMessage(error.error);
58 | setOpen(true);
59 | return;
60 | }
61 |
62 | if (data.result === 'success') {
63 | console.log('Login successful');
64 | window.open('/admin/users', '_self');
65 | } else {
66 | setOpen(true);
67 | }
68 | })
69 | .catch(err => {
70 | console.error(`Error occurred while logging in: ${err}`);
71 | setOpen(true);
72 | });
73 | };
74 |
75 | return (
76 |
77 |
78 |
79 |
87 |
88 | Sign in
89 |
90 |
96 |
106 |
116 | }
118 | label='Remember me'
119 | />
120 |
128 |
134 |
139 | Incorrect username or password: {errorMessage}
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/src/admin/AdminPortal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { styled, createTheme, ThemeProvider } from '@mui/material/styles';
3 | import CssBaseline from '@mui/material/CssBaseline';
4 | import MuiDrawer from '@mui/material/Drawer';
5 | import Box from '@mui/material/Box';
6 | import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar';
7 | import Toolbar from '@mui/material/Toolbar';
8 | import List from '@mui/material/List';
9 | import Typography from '@mui/material/Typography';
10 | import Divider from '@mui/material/Divider';
11 | import IconButton from '@mui/material/IconButton';
12 | import Container from '@mui/material/Container';
13 | import Button from '@mui/material/Button';
14 | import MenuIcon from '@mui/icons-material/Menu';
15 | import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
16 | import { mainListItems, secondaryListItems } from '../ListItems';
17 | import Footer from '../Footer';
18 | import AdminBody from './AdminBody';
19 | import { apiClient } from '@/utils/fetch';
20 |
21 | const drawerWidth: number = 240;
22 |
23 | interface AppBarProps extends MuiAppBarProps {
24 | open?: boolean;
25 | }
26 |
27 | interface AdminPortalProps {
28 | page?: AdminPage;
29 | }
30 |
31 | const AppBar = styled(MuiAppBar, {
32 | shouldForwardProp: prop => prop !== 'open',
33 | })(({ theme, open }) => ({
34 | zIndex: theme.zIndex.drawer + 1,
35 | transition: theme.transitions.create(['width', 'margin'], {
36 | easing: theme.transitions.easing.sharp,
37 | duration: theme.transitions.duration.leavingScreen,
38 | }),
39 | ...(open && {
40 | marginLeft: drawerWidth,
41 | width: `calc(100% - ${drawerWidth}px)`,
42 | transition: theme.transitions.create(['width', 'margin'], {
43 | easing: theme.transitions.easing.sharp,
44 | duration: theme.transitions.duration.enteringScreen,
45 | }),
46 | }),
47 | }));
48 |
49 | const Drawer = styled(MuiDrawer, {
50 | shouldForwardProp: prop => prop !== 'open',
51 | })(({ theme, open }) => ({
52 | '& .MuiDrawer-paper': {
53 | position: 'relative',
54 | whiteSpace: 'nowrap',
55 | width: drawerWidth,
56 | transition: theme.transitions.create('width', {
57 | easing: theme.transitions.easing.sharp,
58 | duration: theme.transitions.duration.enteringScreen,
59 | }),
60 | boxSizing: 'border-box',
61 | ...(!open && {
62 | overflowX: 'hidden',
63 | transition: theme.transitions.create('width', {
64 | easing: theme.transitions.easing.sharp,
65 | duration: theme.transitions.duration.leavingScreen,
66 | }),
67 | width: theme.spacing(7),
68 | [theme.breakpoints.up('sm')]: {
69 | width: theme.spacing(7),
70 | },
71 | }),
72 | },
73 | }));
74 |
75 | const mdTheme = createTheme();
76 |
77 | const logout = () => {
78 | apiClient
79 | .GET('/api/logout')
80 | .then(res => {
81 | const { data, error } = res;
82 | if (!data || error) {
83 | console.log(`Unable to logout: ${error}`);
84 | return;
85 | }
86 | window.location.href = '/login';
87 | })
88 | .catch(err => {
89 | console.log(`Error occurred while logging out: ${err}`);
90 | });
91 | };
92 |
93 | export default function AdminPortal(props: AdminPortalProps) {
94 | const [open, setOpen] = React.useState(false);
95 | const toggleDrawer = () => {
96 | setOpen(!open);
97 | };
98 |
99 | if (props.page === undefined) {
100 | window.open('/admin/qrcode', '_self');
101 | return ;
102 | }
103 |
104 | return (
105 |
106 |
107 |
108 |
109 |
114 |
124 |
125 |
126 |
133 | Admin Portal
134 |
135 |
138 |
139 |
140 |
141 |
149 |
150 |
151 |
152 |
153 |
154 | {mainListItems}
155 |
156 | {secondaryListItems}
157 |
158 |
162 | theme.palette.mode === 'light'
163 | ? theme.palette.grey[100]
164 | : theme.palette.grey[900],
165 | flexGrow: 1,
166 | height: '100vh',
167 | overflow: 'auto',
168 | }}
169 | >
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 | );
179 | }
180 |
--------------------------------------------------------------------------------
/_data/generate_measurment.py:
--------------------------------------------------------------------------------
1 | import csv
2 | import enum
3 | import random
4 | import json
5 |
6 | from dataclasses import dataclass
7 | from datetime import datetime, timedelta, timezone, tzinfo
8 | from typing import Any, Dict, List
9 | from math import radians, degrees, sin, cos, atan2, asin, sqrt
10 | from typing import Tuple
11 |
12 |
13 | R = 6373.0
14 |
15 | def geo_distance(a: Tuple[float, float], b: Tuple[float, float]):
16 | lat1, lon1, lat2, lon2 = map(radians, [*a, *b])
17 |
18 | dlon = lon2 - lon1
19 | dlat = lat2 - lat1
20 |
21 | # Haversine formula
22 | a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
23 | c = 2 * atan2(sqrt(a), sqrt(1 - a))
24 |
25 | return R * c
26 |
27 |
28 | def geo_displace(latitude: float, longitude: float, distance: float, angle: float):
29 | lat1 = radians(latitude)
30 | lon1 = radians(longitude)
31 |
32 | lat2 = asin(sin(lat1)*cos(distance/R) + cos(lat1)*sin(distance/R)*cos(angle))
33 | lon2 = lon1 + atan2(sin(angle)*sin(distance/R)*cos(lat2), cos(distance/R)-sin(lat1)*sin(lat2))
34 |
35 | return degrees(lat2), degrees(lon2)
36 |
37 |
38 | class SiteStatus(enum.Enum):
39 | ACTIVE = 'active'
40 | CONFIRMED = 'confirmed'
41 | IN_CONVERSATION = 'in-conversation'
42 |
43 |
44 | @dataclass(frozen=True, order=True)
45 | class Site:
46 | name: str
47 | latitude: float
48 | longitude: float
49 | status: SiteStatus
50 | address: str
51 |
52 | @staticmethod
53 | def new_site(name: str, latitude: Any, longitude: Any, status: str, address: str):
54 | return Site(name, float(latitude), float(longitude), SiteStatus(status), address)
55 |
56 |
57 | class Stationary:
58 |
59 | id_counter = 0
60 |
61 | def __init__(self, site: Site, latitude: float, longitude: float, start: datetime, end: datetime, step: timedelta):
62 | self.site = site
63 | self.latitude = latitude
64 | self.longitude = longitude
65 | self.start = start
66 | self.end = end
67 | self.step = step
68 | self.id = Stationary.id_counter
69 | Stationary.id_counter += 1
70 |
71 | def generate(self) -> List[Dict[Any, Any]]:
72 | data = []
73 | curr = self.start
74 | distance = 1000 * geo_distance((self.site.latitude, self.site.longitude), (self.latitude, self.longitude))
75 | while curr < self.end:
76 | datum = {}
77 | datum['latitude'] = self.latitude
78 | datum['longitude'] = self.longitude
79 | datum['timestamp'] = curr
80 | # depends on number of devices connected to the site currently
81 | datum['upload_speed'] = None
82 | # depends on number of devices connected to the site currently
83 | datum['download_speed'] = None
84 | # random - range depends on upload/download speed
85 | datum['data_since_last_report'] = None
86 | # datum.signal_strength = min(5, 5 * 50 / distance)
87 | datum['ping'] = distance * 1000 / 343
88 | datum['site'] = self.site.name
89 | datum['device_id'] = self.id
90 | data.append(datum)
91 | curr = curr + self.step
92 |
93 | return data
94 |
95 |
96 | def get_timestamp(a):
97 | return a['timestamp']
98 |
99 |
100 | DEVICES_PER_SITE = 100
101 | STEP = timedelta(minutes=15)
102 | DEVICE_MU = 10
103 | DEVICE_SIGMA = 600
104 | START_DATE = datetime(year=2021, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone.utc)
105 | END_DATE = datetime(year=2021, month=6, day=1, hour=0, minute=0, second=0, tzinfo=timezone.utc)
106 | TIME_RANGE = END_DATE - START_DATE
107 | HOUR = timedelta(hours=1)
108 |
109 | # bit per second
110 | DOWN_SPEED = 1000 * 1000 * 1000
111 | UP_SPEED = 200 * 1000 * 1000
112 |
113 |
114 | def main():
115 | sites: List[Site] = []
116 | with open('./sites.csv') as csvfile:
117 | reader = csv.reader(csvfile, delimiter=',', quotechar='"')
118 | colnames = []
119 | for i, row in enumerate(reader):
120 | if i == 0:
121 | colnames = row
122 | else:
123 | sites.append(Site.new_site(*row))
124 |
125 | measurements = []
126 | for site in sites:
127 | if site.status == SiteStatus.IN_CONVERSATION: continue
128 |
129 | _measurements: List[Dict[Any, Any]] = []
130 | print(site)
131 | for _ in range(DEVICES_PER_SITE):
132 | distance = abs(random.normalvariate(DEVICE_MU, DEVICE_SIGMA)) / 1000
133 | angle = random.uniform(0, 360)
134 |
135 | latitude, longitude = geo_displace(site.latitude, site.longitude, distance, angle)
136 |
137 | time1 = START_DATE + timedelta(seconds=random.uniform(0, TIME_RANGE.total_seconds()))
138 | time2 = START_DATE + timedelta(seconds=random.uniform(0, TIME_RANGE.total_seconds()))
139 | if time1 > time2:
140 | time1, time2 = time2, time1
141 | stationary = Stationary(site, latitude, longitude, time1, time2, STEP)
142 | _measurements.extend(stationary.generate())
143 |
144 | _measurements = sorted(_measurements, key=get_timestamp)
145 | curr = START_DATE + HOUR
146 |
147 | prev_i = 0
148 | curr_i = 0
149 | print(len(_measurements))
150 | while prev_i < len(_measurements):
151 | while curr_i < len(_measurements) and _measurements[curr_i]['timestamp'] < curr:
152 | curr_i += 1
153 |
154 | _slice = _measurements[prev_i:curr_i]
155 | for datum in _slice:
156 | datum['download_speed'] = DOWN_SPEED / len(_slice)
157 | datum['upload_speed'] = UP_SPEED / len(_slice)
158 | total_speed = datum['download_speed'] + datum['upload_speed']
159 | datum['data_since_last_report'] = total_speed * random.uniform(0, 1 * 60 * 60)
160 |
161 | curr += HOUR
162 | prev_i = curr_i
163 |
164 | measurements.extend(_measurements)
165 |
166 | for measurement in measurements:
167 | measurement['timestamp'] = measurement['timestamp'].isoformat()
168 |
169 | with open('./data-small.json', 'w') as output:
170 | output.write(json.dumps(random.sample(measurements, 10000), indent=2))
171 |
172 | with open('./data.json', 'w') as output:
173 | output.write(json.dumps(measurements, indent=2))
174 |
175 |
176 | if __name__ == '__main__':
177 | main()
178 |
--------------------------------------------------------------------------------
/src/admin/UserPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import Table from '@mui/material/Table';
3 | import TableBody from '@mui/material/TableBody';
4 | import TableCell from '@mui/material/TableCell';
5 | import TableHead from '@mui/material/TableHead';
6 | import TableRow from '@mui/material/TableRow';
7 | import Button from '@mui/material/Button';
8 | import Switch from '@mui/material/Switch';
9 | import Paper from '@mui/material/Paper';
10 | import Box from '@mui/material/Box';
11 | import Typography from '@mui/material/Typography';
12 | import NewUserDialog from './NewUserDialog';
13 | import EditIcon from '@mui/icons-material/Edit';
14 | import ViewQRCode from './ViewQRCode';
15 | import ViewIdentity from './ViewIdentity';
16 | import Loading from '../Loading';
17 | import { apiClient } from '@/utils/fetch';
18 |
19 | function handleEnabledChange() {
20 | return;
21 | }
22 |
23 | export default function UserPage() {
24 | const [loadingUser, setLoadingUser] = useState(true);
25 | const [called, setCalled] = useState(false);
26 | const [pendingUsersRows, setPendingUsersRows] = useState([]);
27 | const [activeUsersRows, setActiveUsersRows] = useState([]);
28 |
29 | useEffect(() => {
30 | if (!called) {
31 | apiClient
32 | .POST('/secure/get-users')
33 | .then(resp => {
34 | const { data, error } = resp;
35 | if (!data || error) {
36 | console.log(`Unable to get user: ${error}`);
37 | return;
38 | }
39 | const pending: UserRow[] = data.pending.map(p => ({
40 | ...p,
41 | issueDate: new Date(p.issueDate),
42 | }));
43 | setPendingUsersRows(pending);
44 | const registered: UserRow[] = data.registered.map(r => ({
45 | ...r,
46 | issueDate: new Date(r.issueDate),
47 | }));
48 | setActiveUsersRows(registered);
49 | setLoadingUser(false);
50 | setCalled(true);
51 | })
52 | .catch(err => {
53 | alert(err);
54 | window.open('/login', '_self');
55 | setLoadingUser(false);
56 | console.error(`Error occurred while querying user: ${err}`);
57 | return ;
58 | });
59 | }
60 | });
61 | return (
62 |
63 |
64 |
65 |
66 | Pending Registration
67 |
68 |
69 |
70 |
71 | Issue Date
72 | Identity
73 | Email
74 | Name
75 |
76 |
77 |
78 |
79 |
80 | {pendingUsersRows.map(row => (
81 |
82 | {new Date(row.issueDate).toString()}
83 |
84 |
85 |
86 | {row.email}
87 | {row.firstName + ' ' + row.lastName}
88 |
89 |
90 |
91 |
92 | }
97 | >
98 | Edit
99 |
100 |
101 |
102 | ))}
103 |
104 |
105 |
106 |
107 |
108 | Active Users
109 |
110 |
111 |
112 |
113 | Issue Date
114 | Identity
115 | Email
116 | Name
117 | Enabled
118 | Action
119 |
120 |
121 |
122 | {activeUsersRows.map(row => (
123 |
124 | {new Date(row.issueDate).toString()}
125 |
126 |
127 |
128 | {row.email}
129 | {row.firstName + ' ' + row.lastName}
130 |
131 |
136 |
137 |
138 | }
143 | >
144 | Edit
145 |
146 |
147 |
148 | ))}
149 |
150 |
151 |
152 |
153 |
154 |
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/scripts/publish-version.js:
--------------------------------------------------------------------------------
1 | import { execSync } from 'child_process';
2 | import fs from 'fs';
3 | import path from 'path';
4 | import yaml from 'js-yaml';
5 | import { Octokit } from '@octokit/rest';
6 |
7 |
8 | function updateNestedField(obj, targetArtifactName, value) {
9 | const artifacts = obj.artifacts || [];
10 | for (var artifact of artifacts) {
11 | if (artifact.name === targetArtifactName && artifact.version < value) {
12 | artifact.version = value;
13 | return;
14 | }
15 | }
16 | }
17 |
18 | export default async (pluginConfig, context) => {
19 | const { nextRelease, logger, env } = context;
20 | const newVersion = nextRelease.version;
21 | const { GH_TOKEN, REPO_OWNER, REPO_NAME, TARGET_ARTIFACT_NAME, GIT_COMMITTER_NAME, GIT_COMMITTER_EMAIL } = env;
22 |
23 | const umbrellaRepoOwner = REPO_OWNER || "Local-Connectivity-Lab";
24 | const umbrellaRepoName = REPO_NAME || "ccn-coverage-docker";
25 | const manifestPath = "input-manifest.yml";
26 | const targetArtifactName = TARGET_ARTIFACT_NAME || "ccn-coverage-vis";
27 | const baseBranch = 'main';
28 | const gitUserName = GIT_COMMITTER_NAME || 'scn-git';
29 | const gitUserEmail = GIT_COMMITTER_EMAIL || 'github@seattlecommunitynetwork.org';
30 |
31 | if (!GH_TOKEN) {
32 | logger.error(`GitHub token not found in environment variable.`);
33 | throw new Error(`Missing GitHub token.`);
34 | }
35 |
36 | const octokit = new Octokit({ auth: GH_TOKEN });
37 | const umbrellaRepoUrl = `https://github.com/${umbrellaRepoOwner}/${umbrellaRepoName}.git`;
38 | const localRepoPath = path.resolve(`./tmp-umbrella-repo-${Date.now()}`); // Temporary local path for clone
39 |
40 | const newBranchName = `chore/update-manifest-${newVersion}-${Date.now().toString().slice(-5)}`; // Ensure unique branch name
41 | const commitMessage = `chore(deps): Update manifest ${manifestPath} to version ${newVersion}`;
42 | const prTitle = `Update ${manifestPath} to version ${newVersion}`;
43 | const prBody = `This PR updates the version in \`${manifestPath}\` to \`${newVersion}\` triggered by a new release.`;
44 |
45 | try {
46 | logger.log(`Starting update process for ${umbrellaRepoOwner}/${umbrellaRepoName}`);
47 | logger.log(`Cloning ${umbrellaRepoUrl} to ${localRepoPath}`);
48 |
49 | // // --- 1. Clone the umbrella repository ---
50 | const authenticatedUrl = `https://x-access-token:${GH_TOKEN}@github.com/${umbrellaRepoOwner}/${umbrellaRepoName}.git`;
51 | execSync(`git clone --depth 1 --branch ${baseBranch} ${authenticatedUrl} ${localRepoPath}`, { stdio: 'inherit' });
52 |
53 | // // --- 2. Configure Git User ---
54 | execSync(`git -C ${localRepoPath} config user.name "${gitUserName}"`, { stdio: 'inherit' });
55 | execSync(`git -C ${localRepoPath} config user.email "${gitUserEmail}"`, { stdio: 'inherit' });
56 |
57 | // // --- 3. Create and checkout a new branch ---
58 | logger.log(`Creating new branch: ${newBranchName}`);
59 | execSync(`git -C ${localRepoPath} checkout -b ${newBranchName}`, { stdio: 'inherit' });
60 |
61 | // // --- 4. Read, modify, and write the manifest file ---
62 | const fullManifestPath = path.join(localRepoPath, manifestPath);
63 | logger.log(`Reading manifest file: ${fullManifestPath}`);
64 | if (!fs.existsSync(fullManifestPath)) {
65 | throw new Error(`Manifest file not found at ${fullManifestPath}`);
66 | }
67 | const manifestContent = fs.readFileSync(fullManifestPath, 'utf8');
68 | const manifestData = yaml.load(manifestContent);
69 | updateNestedField(manifestData, targetArtifactName, newVersion);
70 |
71 | logger.log(`Updated '${targetArtifactName}' to '${newVersion}' in manifest data.`);
72 |
73 | const newManifestContent = yaml.dump(manifestData);
74 | fs.writeFileSync(fullManifestPath, newManifestContent, 'utf8');
75 | logger.log(`Successfully wrote updated manifest to ${fullManifestPath}`);
76 |
77 | // // --- 5. Commit and push the changes to the new branch ---
78 | logger.log(`Committing changes to branch ${newBranchName}`);
79 | execSync(`git -C ${localRepoPath} add ${manifestPath}`, { stdio: 'inherit' });
80 | execSync(`git -C ${localRepoPath} commit -m "${commitMessage}"`, { stdio: 'inherit' });
81 |
82 | logger.log(`Pushing branch ${newBranchName} to remote`);
83 | execSync(`git -C ${localRepoPath} push -u origin ${newBranchName}`, { stdio: 'inherit' });
84 |
85 | // // --- 6. Create a Pull Request ---
86 | logger.log(`Creating Pull Request: ${prTitle}`);
87 | const { data: pr } = await octokit.rest.pulls.create({
88 | owner: umbrellaRepoOwner,
89 | repo: umbrellaRepoName,
90 | title: prTitle,
91 | head: newBranchName,
92 | base: baseBranch,
93 | body: prBody,
94 | maintainer_can_modify: true,
95 | });
96 | logger.log(`Pull Request created: ${pr.html_url}`);
97 |
98 | // --- 8. Merge the Pull Request ---
99 | logger.log(`Attempting to merge Pull Request #${pr.number}`);
100 | try {
101 | await octokit.rest.pulls.merge({
102 | owner: umbrellaRepoOwner,
103 | repo: umbrellaRepoName,
104 | pull_number: pr.number,
105 | commit_title: `${commitMessage} (PR #${pr.number})`,
106 | merge_method: 'squash',
107 | });
108 | logger.log(`Pull Request #${pr.number} merged successfully.`);
109 | } catch (mergeError) {
110 | logger.error(`Failed to merge PR #${pr.number}: ${mergeError.message}`);
111 | logger.error(`Details: ${JSON.stringify(mergeError.response?.data, null, 2)}`);
112 | logger.warn(`The PR ${pr.html_url} was created but could not be automatically merged. Manual intervention might be required.`);
113 | throw mergeError;
114 | }
115 |
116 | } catch (error) {
117 | logger.error(`An error occurred: ${error.message}`);
118 | if (error.stderr) {
119 | logger.error(`Stderr: ${error.stderr.toString()}`);
120 | }
121 | if (error.stdout) {
122 | logger.error(`Stdout: ${error.stdout.toString()}`);
123 | }
124 | throw error; // Re-throw to fail the semantic-release step
125 | } finally {
126 | // --- 9. Clean up the temporary local clone ---
127 | if (fs.existsSync(localRepoPath)) {
128 | logger.log(`Cleaning up temporary directory: ${localRepoPath}`);
129 | try {
130 | fs.rmSync(localRepoPath, { recursive: true, force: true });
131 | logger.log('Temporary directory cleaned up.');
132 | } catch (cleanupError) {
133 | logger.warn(`Failed to clean up temporary directory ${localRepoPath}: ${cleanupError.message}`);
134 | }
135 | }
136 | }
137 | };
--------------------------------------------------------------------------------
/src/admin/EditSite.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import Box from '@mui/material/Box';
3 | import Grid from '@mui/material/Grid';
4 | import Paper from '@mui/material/Paper';
5 | import Stack from '@mui/material/Stack';
6 | import Alert from '@mui/material/Alert';
7 | import Snackbar from '@mui/material/Snackbar';
8 | import Button from '@mui/material/Button';
9 | import TextField from '@mui/material/TextField';
10 | import Typography from '@mui/material/Typography';
11 | import EditLocationAltIcon from '@mui/icons-material/EditLocationAlt';
12 | import Loading from '../Loading';
13 | import '../utils/fonts.css';
14 | import { apiClient } from '@/utils/fetch';
15 |
16 | var newSites = '';
17 | export default function EditSite() {
18 | const [loadingSites, setLoadingSites] = useState(true);
19 | const [sites, setSites] = useState('');
20 | // const [newSites, setNewSites] = useState("");
21 | const [openJsonError, setOpenJsonError] = React.useState(false);
22 | const [openApiError, setOpenApiError] = React.useState(false);
23 | const [openSuccess, setOpenSuccess] = React.useState(false);
24 | const handleCloseJsonError = (
25 | event?: React.SyntheticEvent | Event,
26 | reason?: string,
27 | ) => {
28 | if (reason === 'clickaway') {
29 | return;
30 | }
31 | setOpenJsonError(false);
32 | };
33 | const handleCloseApiError = (
34 | event?: React.SyntheticEvent | Event,
35 | reason?: string,
36 | ) => {
37 | if (reason === 'clickaway') {
38 | return;
39 | }
40 | setOpenApiError(false);
41 | };
42 | const handleCloseSuccess = (
43 | event?: React.SyntheticEvent | Event,
44 | reason?: string,
45 | ) => {
46 | if (reason === 'clickaway') {
47 | return;
48 | }
49 | setOpenSuccess(false);
50 | };
51 | const onChange = (e: React.ChangeEvent) => {
52 | newSites = e.target.value;
53 | };
54 | const handleSubmit = () => {
55 | var sitesJson = '';
56 | try {
57 | const siteObj = JSON.parse(newSites);
58 | sitesJson = JSON.stringify(siteObj);
59 | } catch {
60 | setOpenSuccess(false);
61 | setOpenApiError(false);
62 | setOpenJsonError(true);
63 | return;
64 | }
65 |
66 | apiClient
67 | .POST('/secure/edit_sites', {
68 | body: {
69 | sites: sitesJson,
70 | },
71 | })
72 | .then(res => {
73 | const { data, error } = res;
74 | if (error || !data) {
75 | console.log(`Unable to edit site: ${error}`);
76 | return;
77 | }
78 |
79 | setOpenApiError(false);
80 | setOpenJsonError(false);
81 | setOpenSuccess(true);
82 | reloadSites();
83 | })
84 | .catch(err => {
85 | console.error(`Error occurred while editting site: ${err}`);
86 | setOpenApiError(false);
87 | setOpenSuccess(false);
88 | setOpenApiError(true);
89 | });
90 | };
91 | const reloadSites = () => {
92 | apiClient
93 | .GET('/api/sites')
94 | .then(res => {
95 | const { data, error } = res;
96 | if (error || !data) {
97 | console.log(`Unable to query sites: ${error}`);
98 | return;
99 | }
100 |
101 | setLoadingSites(false);
102 | setSites(JSON.stringify(data, null, 2));
103 | newSites = JSON.stringify(data, null, 2);
104 | })
105 | .catch(err => {
106 | setLoadingSites(false);
107 | return ;
108 | });
109 | };
110 | useEffect(() => {
111 | reloadSites();
112 | });
113 | return (
114 |
115 |
116 | }
120 | onClick={handleSubmit}
121 | >
122 | Edit Site
123 |
124 |
125 |
126 | {/* Chart */}
127 |
128 |
135 |
136 | Current Site Information
137 |
138 |
148 |
149 |
150 |
151 |
158 |
159 | New Site Information
160 |
161 |
171 |
172 |
173 |
174 |
175 | }
179 | onClick={handleSubmit}
180 | >
181 | Edit Site
182 |
183 |
184 |
185 |
191 |
196 | Cannot parse JSON
197 |
198 |
199 |
205 |
210 | Internal Server Error
211 |
212 |
213 |
219 |
224 | Success
225 |
226 |
227 |
228 | );
229 | }
230 |
--------------------------------------------------------------------------------
/src/admin/EditData.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import Button from '@mui/material/Button';
3 | import Paper from '@mui/material/Paper';
4 | import Box from '@mui/material/Box';
5 | import Typography from '@mui/material/Typography';
6 | import FormControl from '@mui/material/FormControl';
7 | import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
8 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
9 | import Stack from '@mui/material/Stack';
10 | import SendIcon from '@mui/icons-material/Send';
11 | import Select, { SelectChangeEvent } from '@mui/material/Select';
12 | import MenuItem from '@mui/material/MenuItem';
13 | import TextField from '@mui/material/TextField';
14 | import Dialog from '@mui/material/Dialog';
15 | import DialogActions from '@mui/material/DialogActions';
16 | import DialogContent from '@mui/material/DialogContent';
17 | import DialogContentText from '@mui/material/DialogContentText';
18 | import DialogTitle from '@mui/material/DialogTitle';
19 |
20 | import { styled } from '@mui/material/styles';
21 | import '../utils/fonts.css';
22 | import { apiClient } from '@/utils/fetch';
23 |
24 | const Input = styled('input')({
25 | display: 'none',
26 | });
27 |
28 | // TODO: Remove async and add loading element
29 | // TODO: Return different response, don't rely on alert()
30 | export default function EditData() {
31 | const [csv, setCsv] = useState('');
32 | const [group, setGroup] = useState('');
33 | const [newGroup, setNewGroup] = useState('');
34 | const [groups, setGroups] = useState([]);
35 | const [loading, setLoading] = useState(true);
36 | const [open, setOpen] = React.useState(false);
37 | const handleFileChange = (e: React.ChangeEvent) => {
38 | const reader = new FileReader();
39 | reader.onload = async (e: any) => {
40 | const text = e.target.result || '';
41 | setCsv(text);
42 | };
43 | if (e.target && e.target.files && e.target.files[0]) {
44 | reader.readAsText(e.target.files[0]);
45 | }
46 | };
47 |
48 | const handleSelectChange = (event: SelectChangeEvent) => {
49 | setGroup(event.target.value);
50 | };
51 |
52 | const handleNewGroupChange = (event: React.ChangeEvent) => {
53 | setNewGroup(event.target.value);
54 | };
55 |
56 | const handleClick = () => {
57 | apiClient
58 | .POST('/secure/upload_data', {
59 | body: {
60 | csv: csv,
61 | group: group === '' ? newGroup : group,
62 | },
63 | })
64 | .then(res => {
65 | const { data, error } = res;
66 | if (error || !data) {
67 | console.log(`Unable to upload data: ${error}`);
68 | return;
69 | }
70 |
71 | setLoading(true);
72 | alert('Successfully replaced');
73 | })
74 | .catch(err => {
75 | setLoading(true);
76 | alert(err.response.data.message);
77 | console.log(err.response.data.message);
78 | });
79 | };
80 |
81 | const handleDeleteClick = () => {
82 | apiClient
83 | .POST('/secure/delete_group', {
84 | body: {
85 | group: group,
86 | },
87 | })
88 | .then(res => {
89 | const { data, error } = res;
90 | if (error || !data) {
91 | console.log(`Unable to delete group: ${error}`);
92 | return;
93 | }
94 |
95 | setLoading(true);
96 | alert('Successfully deleted');
97 | })
98 | .catch(err => {
99 | setLoading(true);
100 | alert(err.response.data.message);
101 | console.log(err.response.data.message);
102 | });
103 | };
104 |
105 | const handleClickOpen = () => {
106 | setOpen(true);
107 | };
108 |
109 | const handleClose = () => {
110 | setOpen(false);
111 | };
112 |
113 | const handleDeleteAllClick = () => {
114 | apiClient
115 | .POST('/secure/delete_manual')
116 | .then(res => {
117 | const { data, error } = res;
118 | if (error || !data) {
119 | console.log(`Unable to delete manually: ${error}`);
120 | return;
121 | }
122 |
123 | setLoading(true);
124 | alert('Successfully deleted');
125 | handleClose();
126 | })
127 | .catch(err => {
128 | setLoading(true);
129 | alert(err.response.data.message);
130 | console.log(err.response.data.message);
131 | });
132 | };
133 |
134 | useEffect(() => {
135 | apiClient
136 | .POST('/secure/get_groups')
137 | .then(res => {
138 | const { data, error } = res;
139 | if (!data || error) {
140 | console.log(`Unable to get group: ${error}`);
141 | return;
142 | }
143 | setGroups(data);
144 | setLoading(false);
145 | })
146 | .catch(err => {
147 | if (!err) {
148 | console.log('Unauthorized, please login');
149 | window.open('/admin/login', '_self');
150 | }
151 | console.log(err.response.data.message);
152 | alert(err.response.data.message);
153 | setLoading(false);
154 | });
155 | }, [loading]);
156 |
157 | return (
158 |
159 |
160 |
161 | Update measurement data from file
162 |
163 |
164 |
165 | {/* Collection */}
166 |
180 |
181 |
188 |
189 |
200 | }
204 | variant='contained'
205 | component='span'
206 | disabled={csv === '' ? true : false}
207 | >
208 | Update
209 |
210 | }
214 | variant='contained'
215 | component='span'
216 | disabled={group === '' ? true : false}
217 | >
218 | Remove
219 |
220 |
221 |
222 | }
226 | variant='contained'
227 | component='span'
228 | >
229 | Clear all manual measurements
230 |
231 |
232 |
253 |
254 |
255 | );
256 | }
257 |
--------------------------------------------------------------------------------
/src/vis/LineChart.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import * as d3 from 'd3';
3 |
4 | import { MapType } from './MapSelectionRadio';
5 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
6 | import IconButton from '@mui/material/IconButton';
7 |
8 | import { MULTIPLIERS } from '../utils/measurementMapUtils';
9 | import { solveDisplayOptions } from './DisplaySelection';
10 | import Loading from '../Loading';
11 | import { components } from '../types/api';
12 | import { apiClient } from '../utils/fetch';
13 |
14 | type LineSummaryItemType = components['schemas']['LineSummaryItem'];
15 |
16 | interface LineChartProps {
17 | mapType: MapType;
18 | offset: number;
19 | width: number;
20 | height: number;
21 | selectedSites: SiteOption[];
22 | setLoading: React.Dispatch>;
23 | loading: boolean;
24 | allSites: Site[];
25 | timeFrom: Date;
26 | timeTo: Date;
27 | setDisplayOptions: React.Dispatch>;
28 | displayOptions: DisplayOption[];
29 | }
30 |
31 | const margin = {
32 | left: 40,
33 | bottom: 25,
34 | right: 12,
35 | top: 12,
36 | };
37 |
38 | const mapTypeConvert = {
39 | dbm: 'Signal Strength (dB)',
40 | ping: 'Ping (ms)',
41 | upload_speed: 'Upload Speed (Mbps)',
42 | download_speed: 'Download Speed (Mbps)',
43 | };
44 |
45 | const LineChart = ({
46 | mapType,
47 | offset,
48 | width,
49 | height,
50 | selectedSites,
51 | setLoading,
52 | loading,
53 | allSites,
54 | timeFrom,
55 | timeTo,
56 | setDisplayOptions,
57 | displayOptions,
58 | }: LineChartProps) => {
59 | const [xAxis, setXAxis] =
60 | useState>();
61 | const [yAxis, setYAxis] =
62 | useState>();
63 | const [lines, setLines] =
64 | useState>();
65 | const [yTitle, setYTitle] =
66 | useState>();
67 | const [lineSummary, setLineSummary] = useState();
68 |
69 | useEffect(() => {
70 | const svg = d3.select('#line-chart');
71 | const g = svg
72 | .append('g')
73 | .attr('transform', `translate(${margin.left}, ${margin.top})`);
74 |
75 | setXAxis(g.append('g'));
76 | setYAxis(g.append('g'));
77 | setLines(g.append('g'));
78 | setYTitle(
79 | g.append('g').attr('transform', 'translate(0,10)').append('text'),
80 | );
81 | g.append('g').attr('transform', 'translate(0,0)').append('text');
82 | setLoading(false);
83 | }, [setXAxis, setYAxis, setLines, setYTitle, setLoading]);
84 | useEffect(() => {
85 | (async () => {
86 | const _selectedSites = selectedSites.map(ss => ss.label);
87 | if (selectedSites.length === 0) {
88 | return;
89 | }
90 |
91 | const { data, error } = await apiClient.GET('/api/lineSummary', {
92 | params: {
93 | query: {
94 | mapType: mapType,
95 | selectedSites: _selectedSites.join(','),
96 | timeFrom: timeFrom.toISOString(),
97 | timeTo: timeTo.toISOString(),
98 | },
99 | },
100 | });
101 |
102 | if (!data) {
103 | console.error(`Unable to query line summary: ${error}`);
104 | return;
105 | }
106 |
107 | setLineSummary(data);
108 | })();
109 | }, [mapType, selectedSites, timeFrom, timeTo]);
110 | useEffect(() => {
111 | if (!xAxis || !yAxis || !lines || !yTitle || !lineSummary) return;
112 | (async function () {
113 | setLoading(true);
114 | let colors: { [name: string]: string } = {};
115 | for (let site of allSites) {
116 | colors[site.name] = site.color ?? '#000000';
117 | }
118 | const data: {
119 | site: string;
120 | values: { date: Date; value: number }[];
121 | }[] = lineSummary.map((d: any) => ({
122 | site: d.site,
123 | values: d.values.map((v: any) => ({
124 | date: new Date(v.date),
125 | value: v.value,
126 | })),
127 | }));
128 |
129 | const chartWidth = width - margin.left - margin.right;
130 | const chartHeight = height - margin.top - margin.bottom;
131 |
132 | const flat = data.map(a => a.values).flat();
133 | const xScale = d3
134 | .scaleTime()
135 | .domain(
136 | d3
137 | .extent(flat, d => new Date(d.date))
138 | .map((d?: Date) => d ?? new Date(0)),
139 | )
140 | .range([0, chartWidth]);
141 | let yScale: any;
142 | if (mapType === 'dbm') {
143 | yScale = d3
144 | .scaleLinear()
145 | .domain([
146 | (d3.max(flat, d => d.value) ?? 1) * MULTIPLIERS[mapType],
147 | (d3.min(flat, d => d.value) ?? 1) * MULTIPLIERS[mapType],
148 | ])
149 | .range([0, chartHeight]);
150 | } else {
151 | yScale = d3
152 | .scaleLinear()
153 | .domain([0, (d3.max(flat, d => d.value) ?? 1) * MULTIPLIERS[mapType]])
154 | .range([chartHeight, 0]);
155 | }
156 |
157 | const xAxisGenerator = d3.axisBottom(xScale);
158 |
159 | const yAxisGenerator = d3.axisLeft(yScale);
160 |
161 | const lineGenerator = d3
162 | .line<{ date: Date; value: number }>()
163 | .x(d => xScale(d.date))
164 | .y(d => yScale(d.value * MULTIPLIERS[mapType]));
165 |
166 | // ----------------------------------------- CHART --------------------------------------------------
167 |
168 | const svg = d3.select('#line-chart');
169 | const tooltip = d3
170 | .select('#line-tooltip')
171 | .style('position', 'absolute')
172 | .style('background-color', 'white')
173 | .style('border-radius', '4px')
174 | .style(
175 | 'box-shadow',
176 | '0px 2px 1px -1px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 1px 3px 0px rgba(0,0,0,0.12)',
177 | )
178 | .style('padding', '4px')
179 | .style('font-size', 'small')
180 | .style('opacity', 1)
181 | .style('display', 'none');
182 |
183 | svg.attr('width', width).attr('height', height);
184 |
185 | xAxis
186 | .attr('transform', `translate(0, ${chartHeight})`)
187 | .style('user-select', 'none')
188 | .transition()
189 | .duration(0)
190 | .call(xAxisGenerator);
191 |
192 | yAxis
193 | .style('user-select', 'none')
194 | .transition()
195 | .duration(0)
196 | .call(yAxisGenerator);
197 |
198 | yTitle
199 | .attr('x', 3)
200 | .style('user-select', 'none')
201 | .attr('font-size', 12)
202 | .attr('text-anchor', 'start')
203 | .attr('font-weight', 'bold')
204 | .text(mapTypeConvert[mapType]);
205 |
206 | lines
207 | .selectAll('.line')
208 | .data(data, (d: any) => d.site)
209 | .join(
210 | enter =>
211 | enter
212 | .append('path')
213 | .attr('d', d =>
214 | lineGenerator(d.values.map(({ date }) => ({ date, value: 0 }))),
215 | )
216 | .attr('class', 'line')
217 | .style('fill', 'none')
218 | .style('stroke', d => colors[d.site] + '')
219 | .style('stroke-width', 2)
220 | .style('stroke-linejoin', 'round')
221 | .style('opacity', 0)
222 | .on('mouseover', (_, d) =>
223 | tooltip.style('display', 'inline').html(d.site),
224 | )
225 | .on('mousemove', (event, d) =>
226 | tooltip
227 | .html(
228 | d.site +
229 | '
' +
230 | (() => {
231 | const idx = Math.floor(
232 | d.values.length *
233 | ((event.offsetX - margin.left) / chartWidth),
234 | );
235 | return idx >= 0 && idx < d.values.length
236 | ? d.values[idx]?.value.toFixed(2)
237 | : '';
238 | })(),
239 | )
240 | .style('left', event.offsetX - 120 + 'px')
241 | .style('top', event.offsetY - 50 + 'px'),
242 | )
243 | .on('mouseout', () => tooltip.style('display', 'none')),
244 | update => update,
245 | exit => exit.remove(),
246 | )
247 | .transition()
248 | .duration(500)
249 | .style('opacity', 1)
250 | .attr('d', d => lineGenerator(d.values));
251 | setLoading(false);
252 | })();
253 | }, [
254 | mapType,
255 | xAxis,
256 | yAxis,
257 | lines,
258 | yTitle,
259 | height,
260 | setLoading,
261 | width,
262 | selectedSites,
263 | allSites,
264 | lineSummary,
265 | timeFrom,
266 | timeTo,
267 | ]);
268 |
269 | return (
270 | <>
271 |
275 | {
277 | setDisplayOptions(
278 | solveDisplayOptions(displayOptions, 'displayGraph', false),
279 | );
280 | }}
281 | >
282 |
283 |
284 |
285 |
286 |
287 |
293 |
294 |
295 | >
296 | );
297 | };
298 |
299 | export default LineChart;
300 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
--------------------------------------------------------------------------------
/src/vis/Vis.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { styled, createTheme, ThemeProvider } from '@mui/material/styles';
3 | import CssBaseline from '@mui/material/CssBaseline';
4 | import MuiDrawer from '@mui/material/Drawer';
5 | import Box from '@mui/material/Box';
6 | import Card from '@mui/material/Card';
7 | import Fade from '@mui/material/Fade';
8 | import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar';
9 | import Toolbar from '@mui/material/Toolbar';
10 | import List from '@mui/material/List';
11 | import Typography from '@mui/material/Typography';
12 | import Divider from '@mui/material/Divider';
13 | import IconButton from '@mui/material/IconButton';
14 | import Container from '@mui/material/Container';
15 | import MenuIcon from '@mui/icons-material/Menu';
16 | import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
17 | import Visibility from '@mui/icons-material/Visibility';
18 | import { homeListItems } from '../ListItems';
19 | import MapSelectionRadio, { MapType } from './MapSelectionRadio';
20 | import DisplaySelection from './DisplaySelection';
21 | import SiteSelect from './SiteSelect';
22 | import DeviceSelect from './DeviceSelect';
23 | import DateSelect from './DateSelect';
24 | import MeasurementMap from './MeasurementMap';
25 | import LineChart from './LineChart';
26 | import { UNITS, MAP_TYPE_CONVERT } from '../utils/measurementMapUtils';
27 | import { solveDisplayOptions } from './DisplaySelection';
28 | import { apiClient } from '@/utils/fetch';
29 |
30 | // import { setOptions } from 'leaflet';
31 |
32 | const drawerWidth: number = 320;
33 | const maxChartWidth: number = 400;
34 |
35 | interface AppBarProps extends MuiAppBarProps {
36 | open?: boolean;
37 | }
38 |
39 | const AppBar = styled(MuiAppBar, {
40 | shouldForwardProp: prop => prop !== 'open',
41 | })(({ theme, open }) => ({
42 | zIndex: theme.zIndex.drawer + 1,
43 | transition: theme.transitions.create(['width', 'margin'], {
44 | easing: theme.transitions.easing.sharp,
45 | duration: theme.transitions.duration.leavingScreen,
46 | }),
47 | ...(open && {
48 | marginLeft: drawerWidth,
49 | width: `calc(100% - ${drawerWidth}px)`,
50 | transition: theme.transitions.create(['width', 'margin'], {
51 | easing: theme.transitions.easing.sharp,
52 | duration: theme.transitions.duration.enteringScreen,
53 | }),
54 | }),
55 | }));
56 |
57 | const Drawer = styled(MuiDrawer, {
58 | shouldForwardProp: prop => prop !== 'open',
59 | })(({ theme, open }) => ({
60 | '& .MuiDrawer-paper': {
61 | position: 'relative',
62 | whiteSpace: 'nowrap',
63 | width: drawerWidth,
64 | height: window.innerHeight,
65 | transition: theme.transitions.create('width', {
66 | easing: theme.transitions.easing.sharp,
67 | duration: theme.transitions.duration.enteringScreen,
68 | }),
69 | boxSizing: 'border-box',
70 | ...(!open && {
71 | overflowX: 'hidden',
72 | transition: theme.transitions.create('width', {
73 | easing: theme.transitions.easing.sharp,
74 | duration: theme.transitions.duration.leavingScreen,
75 | }),
76 | width: 0,
77 | [theme.breakpoints.up('sm')]: {
78 | width: 0,
79 | },
80 | }),
81 | },
82 | }));
83 |
84 | function getWindowDimensions() {
85 | const { innerWidth: width, innerHeight: height } = window;
86 | return {
87 | width,
88 | height,
89 | };
90 | }
91 |
92 | function useWindowDimensions() {
93 | const [windowDimensions, setWindowDimensions] = useState(
94 | getWindowDimensions(),
95 | );
96 |
97 | useEffect(() => {
98 | function handleResize() {
99 | setWindowDimensions(getWindowDimensions());
100 | }
101 |
102 | window.addEventListener('resize', handleResize);
103 | return () => window.removeEventListener('resize', handleResize);
104 | }, []);
105 |
106 | return windowDimensions;
107 | }
108 |
109 | const mdTheme = createTheme();
110 |
111 | const INITIAL_DISPLAY_OPTIONS = [
112 | {
113 | label: 'Chart',
114 | name: 'displayGraph',
115 | checked: true,
116 | },
117 | {
118 | label: 'Data Overlay',
119 | name: 'displayOverlayData',
120 | checked: true,
121 | },
122 | ];
123 |
124 | function displayValue(displayOptions: DisplayOption[], name: string) {
125 | for (let option of displayOptions) {
126 | if (option.name === name && option.checked === true) {
127 | return true;
128 | }
129 | }
130 | return false;
131 | }
132 |
133 | export default function Vis() {
134 | const [mapType, setMapType] = useState('dbm');
135 | const [sites, setSites] = useState([]);
136 | const [selectedCells, setSelectedCells] = useState>(new Set());
137 | const [siteOptions, setSiteOptions] = useState([]);
138 | const [selectedSites, setSelectedSites] = useState(siteOptions);
139 | const [selectedDevices, setSelectedDevices] = useState([]);
140 | const [timeFrom, setTimeFrom] = useState(
141 | new Date('2021-09-01T00:00:00'),
142 | );
143 | const [timeTo, setTimeTo] = useState(new Date());
144 | const [displayOptions, setDisplayOptions] = useState(
145 | INITIAL_DISPLAY_OPTIONS,
146 | );
147 |
148 | const [overlayData, setOverlayData] = useState(0);
149 | useEffect(() => {
150 | (async () => {
151 | try {
152 | const { data, error } = await apiClient.GET('/api/sites');
153 | if (!data || error) {
154 | console.log(`Cannot fetch site info: ${error}`);
155 | return;
156 | }
157 |
158 | const siteOptions = data.map(({ name, status }) => ({
159 | label: name,
160 | value: name,
161 | status: status,
162 | }));
163 | setSites(data);
164 | setSiteOptions(siteOptions);
165 | setSelectedSites(siteOptions);
166 | } catch (error) {
167 | console.error(`Error while fetching site data: ${error}`);
168 | return;
169 | }
170 | })();
171 | }, []);
172 | const [loadingMap, setLoadingMap] = useState(true);
173 | const [loadingLine, setLoadingLine] = useState(true);
174 | const { height, width } = useWindowDimensions();
175 | const chartWidth: number = Math.min(width * 0.9 - 50, maxChartWidth);
176 | const chartHeight: number = 0.42 * chartWidth;
177 | var drawerOpen: boolean = true;
178 | var barHeight: number = 64;
179 | if (width < 600) {
180 | drawerOpen = false;
181 | barHeight = 52;
182 | }
183 | const [open, setOpen] = React.useState(drawerOpen);
184 |
185 | document.title = 'Performance Evaluation';
186 | const toggleDrawer = () => {
187 | setOpen(!open);
188 | };
189 |
190 | return (
191 |
192 |
193 |
194 |
195 |
200 |
210 |
211 |
212 |
219 | Performance Evaluation
220 |
221 |
222 |
223 |
224 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
243 |
248 |
255 |
261 | {/* */}
266 |
267 |
268 |
269 | {homeListItems}
270 |
271 |
272 |
289 |
290 |
301 |
306 |
307 |
321 |
322 |
323 |
324 |
325 |
341 | {
343 | setDisplayOptions(
344 | solveDisplayOptions(displayOptions, 'displayGraph', true),
345 | );
346 | }}
347 | >
348 |
349 |
350 |
351 |
366 |
371 |
372 |
373 | Average {MAP_TYPE_CONVERT[mapType]}
374 |
375 |
376 | {overlayData
377 | ? overlayData.toFixed(2) + ' ' + UNITS[mapType]
378 | : 'Please select the area'}
379 |
380 |
381 |
382 |
383 |
384 | );
385 | }
386 |
--------------------------------------------------------------------------------
/src/vis/MeasurementMap.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { MapType } from './MapSelectionRadio';
3 | import { API_URL } from '../utils/config';
4 | import * as L from 'leaflet';
5 | import * as d3 from 'd3';
6 | import {
7 | siteMarker,
8 | siteSmallMarker,
9 | isSiteArray,
10 | } from '../leaflet-component/site-marker';
11 | import getBounds from '../utils/get-bounds';
12 | import MapLegend from './MapLegend';
13 | import Loading from '../Loading';
14 | import { GeoSearchControl, OpenStreetMapProvider } from 'leaflet-geosearch';
15 | import 'leaflet-geosearch/dist/geosearch.css';
16 | import { apiClient } from '../utils/fetch';
17 | import { components } from '../types/api';
18 | import {
19 | MULTIPLIERS,
20 | UNITS,
21 | MAP_TYPE_CONVERT,
22 | } from '../utils/measurementMapUtils';
23 |
24 | type SitesSummaryType = components['schemas']['SitesSummary'];
25 | type QueryDataType = components['schemas']['QueryData'];
26 | type MarkerData = components['schemas']['MarkerData'];
27 |
28 | // Updated with details from: https://stadiamaps.com/stamen/onboarding/migrate/
29 | const ATTRIBUTION =
30 | '© Stadia Maps ' +
31 | '© Stamen Design ' +
32 | '© OpenMapTiles ' +
33 | '© OpenStreetMap contributors';
34 |
35 | const URL = `https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}${
36 | devicePixelRatio > 1 ? '@2x' : ''
37 | }.png`;
38 |
39 | const BIN_SIZE_SHIFT = 0;
40 | const DEFAULT_ZOOM = 10;
41 | const LEGEND_WIDTH = 25;
42 |
43 | function cts(p: Cell): string {
44 | return p.x + ',' + p.y;
45 | }
46 |
47 | interface MapProps {
48 | mapType: MapType;
49 | selectedSites: SiteOption[];
50 | selectedDevices: DeviceOption[];
51 | setLoading: React.Dispatch>;
52 | width: number;
53 | height: number;
54 | loading: boolean;
55 | top: number;
56 | allSites: Site[];
57 | cells: Set;
58 | setCells: React.Dispatch>>;
59 | overlayData: number;
60 | setOverlayData: React.Dispatch>;
61 | timeFrom: Date;
62 | timeTo: Date;
63 | }
64 |
65 | const MeasurementMap = ({
66 | mapType,
67 | selectedSites,
68 | selectedDevices,
69 | setLoading,
70 | width,
71 | height,
72 | loading,
73 | top,
74 | allSites,
75 | cells,
76 | setCells,
77 | overlayData,
78 | setOverlayData,
79 | timeFrom,
80 | timeTo,
81 | }: MapProps) => {
82 | const [cDomain, setCDomain] = useState();
83 | const [map, setMap] = useState();
84 | const [bins, setBins] = useState([]);
85 | const [bounds, setBounds] = useState<{
86 | left: number;
87 | top: number;
88 | width: number;
89 | height: number;
90 | }>();
91 | // Data squares
92 | const [layer, setLayer] = useState();
93 | // Layer for boundaries
94 | const [blayer, setBLayer] = useState();
95 | // Markers for sites
96 | const [mlayer, setMLayer] = useState();
97 | const [sitesSummary, setSiteSummary] = useState();
98 | // Markers for manual data points
99 | const [slayer, setSLayer] = useState();
100 | const [llayer, setLLayer] = useState();
101 | const [markerData, setMarkerData] = useState();
102 |
103 | useEffect(() => {
104 | (async () => {
105 | const { data, error } = await apiClient.GET('/api/dataRange');
106 | if (!data) {
107 | console.error(`unable to fetch data range: ${error}`);
108 | return;
109 | }
110 |
111 | if (!(Array.isArray(data.center) && data.center.length === 2)) {
112 | console.error(`data range is invalid.`);
113 | return;
114 | }
115 |
116 | const center = data.center as [number, number];
117 |
118 | const _map = L.map('map-id').setView(
119 | { lat: center[0], lng: center[1] },
120 | DEFAULT_ZOOM,
121 | );
122 | const _bounds = getBounds({
123 | ...data,
124 | map: _map,
125 | center: center,
126 | width,
127 | height,
128 | });
129 |
130 | L.tileLayer(URL, {
131 | attribution: ATTRIBUTION,
132 | maxZoom: 16,
133 | minZoom: 10,
134 | opacity: 0.7,
135 | zIndex: 1,
136 | }).addTo(_map);
137 |
138 | setBounds(_bounds);
139 | setMap(_map);
140 | setLayer(L.layerGroup().addTo(_map));
141 | setBLayer(L.layerGroup().addTo(_map));
142 | setSLayer(L.layerGroup().addTo(_map));
143 | setMLayer(L.layerGroup().addTo(_map));
144 | setLLayer(L.layerGroup().addTo(_map));
145 |
146 | const search = new (GeoSearchControl as any)({
147 | provider: new OpenStreetMapProvider({
148 | params: {
149 | countrycodes: 'us', // limit to USA
150 | },
151 | }),
152 | style: 'bar', // optional: bar|button - default button
153 | });
154 | _map.addControl(search);
155 | })();
156 | }, [width, height]);
157 |
158 | useEffect(() => {
159 | (async () => {
160 | if (allSites.length === 0) {
161 | return;
162 | }
163 |
164 | const { data, error } = await apiClient.GET('/api/sitesSummary', {
165 | params: {
166 | query: {
167 | timeFrom: timeFrom.toISOString(),
168 | timeTo: timeTo.toISOString(),
169 | },
170 | },
171 | });
172 |
173 | if (!data) {
174 | console.error(`Unable to query site summary: ${error}`);
175 | return;
176 | }
177 |
178 | setSiteSummary(data);
179 | })();
180 | }, [allSites, timeFrom, timeTo]);
181 |
182 | useEffect(() => {
183 | if (!map || !sitesSummary || !slayer || !blayer) return;
184 | // TODO: MOVE TO UTILS;
185 | const greenIcon = new L.Icon({
186 | iconUrl:
187 | 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
188 | shadowUrl:
189 | 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
190 | iconSize: [25, 41],
191 | iconAnchor: [12, 41],
192 | popupAnchor: [1, -34],
193 | shadowSize: [41, 41],
194 | });
195 | const goldIcon = new L.Icon({
196 | iconUrl:
197 | 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-gold.png',
198 | shadowUrl:
199 | 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
200 | iconSize: [25, 41],
201 | iconAnchor: [12, 41],
202 | popupAnchor: [1, -34],
203 | shadowSize: [41, 41],
204 | });
205 | const redIcon = new L.Icon({
206 | iconUrl:
207 | 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
208 | shadowUrl:
209 | 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
210 | iconSize: [25, 41],
211 | iconAnchor: [12, 41],
212 | popupAnchor: [1, -34],
213 | shadowSize: [41, 41],
214 | });
215 |
216 | slayer.clearLayers();
217 | const _markers = new Map();
218 | const _sites: Site[] = allSites || [];
219 | if (!isSiteArray(_sites)) {
220 | throw new Error('data has incorrect type');
221 | }
222 | blayer.clearLayers();
223 | for (let site of _sites) {
224 | if (site.boundary) {
225 | L.polygon(site.boundary, { color: site.color ?? 'black' }).addTo(
226 | blayer,
227 | );
228 | }
229 | const summary = sitesSummary[site.name];
230 | if (!summary) {
231 | console.warn(`Unknown site: ${site.name}`);
232 | continue;
233 | }
234 | _markers.set(site.name, siteMarker(site, summary, map).addTo(slayer));
235 | }
236 | _markers.forEach((marker, site) => {
237 | if (selectedSites.some(s => s.label === site)) {
238 | marker.setOpacity(1);
239 | } else {
240 | marker.setOpacity(0.5);
241 | }
242 | if (allSites.some(s => s.name === site && s.status === 'active')) {
243 | marker.setIcon(greenIcon);
244 | } else if (
245 | allSites.some(s => s.name === site && s.status === 'confirmed')
246 | ) {
247 | marker.setIcon(goldIcon);
248 | } else if (
249 | allSites.some(s => s.name === site && s.status === 'in-conversation')
250 | ) {
251 | marker.setIcon(redIcon);
252 | }
253 | });
254 | }, [selectedSites, map, allSites, sitesSummary, slayer, blayer]);
255 |
256 | useEffect(() => {
257 | (async () => {
258 | if (selectedSites.length === 0 || selectedDevices.length === 0) {
259 | setMarkerData([]);
260 | return;
261 | }
262 |
263 | try {
264 | const { data, error } = await apiClient.GET('/api/markers', {
265 | params: {
266 | query: {
267 | sites: selectedSites.map(ss => ss.label).join(','),
268 | devices: selectedDevices.map(ss => ss.label).join(','),
269 | timeFrom: timeFrom.toISOString(),
270 | timeTo: timeTo.toISOString(),
271 | },
272 | },
273 | });
274 |
275 | if (!data || error) {
276 | console.error(`Unable to fetch marker data: ${error}`);
277 | return;
278 | }
279 |
280 | setMarkerData(data);
281 | } catch (error) {
282 | console.error(`Error occurred while fetching marker data: ${error}`);
283 | setMarkerData([]);
284 | }
285 | })();
286 | }, [selectedSites, selectedDevices, timeFrom, timeTo]);
287 |
288 | useEffect(() => {
289 | if (!map || !markerData || !llayer) return;
290 | llayer.clearLayers();
291 | const _markers = new Map();
292 | markerData.forEach(m =>
293 | _markers.set(m.mid, siteSmallMarker(m).addTo(llayer)),
294 | );
295 | const smallIcon = new L.Icon({
296 | iconUrl:
297 | 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-grey.png',
298 | // shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
299 | iconSize: [20, 35],
300 | iconAnchor: [12, 35],
301 | popupAnchor: [1, -34],
302 | // shadowSize: [35, 35]
303 | });
304 | _markers.forEach((marker, site) => {
305 | marker.setIcon(smallIcon);
306 | });
307 | }, [markerData, map, llayer]);
308 |
309 | useEffect(() => {
310 | if (!map || !bounds || !layer) return;
311 |
312 | (async () => {
313 | if (!map) {
314 | return;
315 | }
316 |
317 | const { data, error } = await apiClient.GET('/api/data', {
318 | params: {
319 | query: {
320 | width: bounds.width,
321 | height: bounds.height,
322 | left: bounds.left,
323 | top: bounds.top,
324 | binSizeShift: BIN_SIZE_SHIFT,
325 | zoom: DEFAULT_ZOOM,
326 | selectedSites: selectedSites.map(ss => ss.label).join(','),
327 | mapType: mapType,
328 | timeFrom: timeFrom.toISOString(),
329 | timeTo: timeTo.toISOString(),
330 | },
331 | },
332 | });
333 |
334 | if (!data) {
335 | console.error(`Cannot query data: ${error}`);
336 | return;
337 | }
338 |
339 | setBins(data);
340 | })();
341 | }, [
342 | selectedSites,
343 | mapType,
344 | setLoading,
345 | map,
346 | layer,
347 | bounds,
348 | timeFrom,
349 | timeTo,
350 | ]);
351 |
352 | useEffect(() => {
353 | if (!map || !bounds || !layer) return;
354 |
355 | setLoading(true);
356 | (async () => {
357 | const colorDomain = [
358 | d3.max(bins, b => Number(b.average) * MULTIPLIERS[mapType]) ?? 1,
359 | d3.min(bins, b => Number(b.average) * MULTIPLIERS[mapType]) ?? 0,
360 | ];
361 |
362 | const colorScale = d3.scaleSequential(colorDomain, d3.interpolateViridis);
363 | setCDomain(colorDomain);
364 |
365 | layer.clearLayers();
366 | bins.forEach(p => {
367 | const idx = p.bin;
368 | const bin = Number(p.average);
369 | if (bin) {
370 | const x = ((idx / bounds.height) << BIN_SIZE_SHIFT) + bounds.left;
371 | const y = (idx % bounds.height << BIN_SIZE_SHIFT) + bounds.top;
372 |
373 | const sw = map.unproject([x, y], DEFAULT_ZOOM);
374 | const ne = map.unproject(
375 | [x + (1 << BIN_SIZE_SHIFT), y + (1 << BIN_SIZE_SHIFT)],
376 | DEFAULT_ZOOM,
377 | );
378 |
379 | L.rectangle(L.latLngBounds(sw, ne), {
380 | fillColor: colorScale(bin * MULTIPLIERS[mapType]),
381 | fillOpacity: 0.75,
382 | stroke: false,
383 | })
384 | .bindTooltip(`${bin.toFixed(2)} ${UNITS[mapType]}`, {
385 | direction: 'top',
386 | })
387 | .addTo(layer)
388 | .on('click', e => {
389 | const cs = cells;
390 | const c = cts({ x: x, y: y });
391 | if (cs.has(c)) {
392 | cs.delete(c);
393 | } else {
394 | cs.add(c);
395 | }
396 | setCells(new Set(cs));
397 | // console.log(cs);
398 | });
399 | }
400 | });
401 | setLoading(false);
402 | })();
403 | }, [
404 | bins,
405 | setCells,
406 | cells,
407 | selectedSites,
408 | mapType,
409 | setLoading,
410 | map,
411 | layer,
412 | bounds,
413 | ]);
414 |
415 | useEffect(() => {
416 | if (!map || !bounds || !layer || !mlayer || !bins) return;
417 | (async () => {
418 | mlayer.clearLayers();
419 | var binSum: number = 0;
420 | var binNum: number = 0;
421 | bins.forEach(p => {
422 | const idx = p.bin;
423 | const bin = Number(p.average);
424 | if (bin) {
425 | const x = ((idx / bounds.height) << BIN_SIZE_SHIFT) + bounds.left;
426 | const y = (idx % bounds.height << BIN_SIZE_SHIFT) + bounds.top;
427 | const c = cts({ x: x, y: y });
428 | if (cells.has(c)) {
429 | const ct = map.unproject(
430 | [x + (1 << BIN_SIZE_SHIFT) / 2, y + (1 << BIN_SIZE_SHIFT) / 2],
431 | DEFAULT_ZOOM,
432 | );
433 | binSum += bin;
434 | binNum += 1;
435 | L.circle(L.latLng(ct), {
436 | fillColor: '#FF0000',
437 | fillOpacity: 0.75,
438 | radius: 24,
439 | stroke: false,
440 | })
441 | .bindTooltip(`${bin.toFixed(2)}`, { direction: 'top' })
442 | .addTo(mlayer)
443 | .on('click', e => {
444 | const cs = cells;
445 | if (cs.has(c)) {
446 | cs.delete(c);
447 | } else {
448 | cs.add(c);
449 | }
450 | // console.log(cs);
451 | setCells(new Set(cs));
452 | });
453 | }
454 | }
455 | });
456 | setOverlayData(binSum / binNum);
457 | })();
458 | }, [
459 | cells,
460 | setCells,
461 | setOverlayData,
462 | bins,
463 | selectedSites,
464 | mapType,
465 | setLoading,
466 | map,
467 | mlayer,
468 | bounds,
469 | layer,
470 | ]);
471 |
472 | return (
473 |
474 |
478 |
479 |
484 |
485 |
486 |
487 | );
488 | };
489 |
490 | export default MeasurementMap;
491 |
--------------------------------------------------------------------------------
/src/types/api.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file was auto-generated by openapi-typescript.
3 | * Do not make direct changes to the file.
4 | */
5 |
6 | export interface paths {
7 | '/api/register': {
8 | parameters: {
9 | query?: never;
10 | header?: never;
11 | path?: never;
12 | cookie?: never;
13 | };
14 | get?: never;
15 | put?: never;
16 | /** Registers a new user */
17 | post: operations['registerUser'];
18 | delete?: never;
19 | options?: never;
20 | head?: never;
21 | patch?: never;
22 | trace?: never;
23 | };
24 | '/api/report_signal': {
25 | parameters: {
26 | query?: never;
27 | header?: never;
28 | path?: never;
29 | cookie?: never;
30 | };
31 | get?: never;
32 | put?: never;
33 | /** Report a signal strength measurement. */
34 | post: operations['reportSignal'];
35 | delete?: never;
36 | options?: never;
37 | head?: never;
38 | patch?: never;
39 | trace?: never;
40 | };
41 | '/api/report_measurement': {
42 | parameters: {
43 | query?: never;
44 | header?: never;
45 | path?: never;
46 | cookie?: never;
47 | };
48 | get?: never;
49 | put?: never;
50 | /** Report a speed test measurement. */
51 | post: operations['reportMeasurement'];
52 | delete?: never;
53 | options?: never;
54 | head?: never;
55 | patch?: never;
56 | trace?: never;
57 | };
58 | '/api/data': {
59 | parameters: {
60 | query?: never;
61 | header?: never;
62 | path?: never;
63 | cookie?: never;
64 | };
65 | /**
66 | * Retrieve network data
67 | * @description Fetches network data with optional filtering by cell_id or timestamp range, and visual display parameters. When filtering by timestamp, both timestamp_from and timestamp_to must be provided together to define a date range. Results are always sorted by timestamp.
68 | */
69 | get: {
70 | parameters: {
71 | query?: {
72 | /** @description Filter results by cell identifier */
73 | cell_id?: string;
74 | /** @description Start of timestamp range - must be used together with timestamp_to */
75 | timestamp_from?: string;
76 | /** @description End of timestamp range - must be used together with timestamp_from */
77 | timestamp_to?: string;
78 | /** @description Width of the display area in pixels */
79 | width?: number;
80 | /** @description Height of the display area in pixels */
81 | height?: number;
82 | /** @description Top coordinate of the viewport */
83 | top?: number;
84 | /** @description Left coordinate of the viewport */
85 | left?: number;
86 | /** @description Controls the size of data bins for aggregation */
87 | binSizeShift?: number;
88 | /** @description Zoom level for the map view */
89 | zoom?: number;
90 | /** @description Comma-separated list of selected site identifiers */
91 | selectedSites?: string;
92 | /** @description Type of map visualization to display */
93 | mapType?: string;
94 | /** @description Alternative format for timestamp_from */
95 | timeFrom?: string;
96 | /** @description Alternative format for timestamp_to */
97 | timeTo?: string;
98 | };
99 | header?: never;
100 | path?: never;
101 | cookie?: never;
102 | };
103 | requestBody?: never;
104 | responses: {
105 | /** @description A list of network data records, sorted by timestamp */
106 | 200: {
107 | headers: {
108 | [name: string]: unknown;
109 | };
110 | content: {
111 | 'application/json': components['schemas']['QueryData'][];
112 | };
113 | };
114 | /** @description Invalid input */
115 | 400: {
116 | headers: {
117 | [name: string]: unknown;
118 | };
119 | content?: never;
120 | };
121 | };
122 | };
123 | put?: never;
124 | post?: never;
125 | delete?: never;
126 | options?: never;
127 | head?: never;
128 | patch?: never;
129 | trace?: never;
130 | };
131 | '/api/sucess': {
132 | parameters: {
133 | query?: never;
134 | header?: never;
135 | path?: never;
136 | cookie?: never;
137 | };
138 | /**
139 | * Success response
140 | * @description Returns a success message, typically used as a redirect target after successful authentication
141 | */
142 | get: {
143 | parameters: {
144 | query?: never;
145 | header?: never;
146 | path?: never;
147 | cookie?: never;
148 | };
149 | requestBody?: never;
150 | responses: {
151 | /** @description Success */
152 | 200: {
153 | headers: {
154 | [name: string]: unknown;
155 | };
156 | content: {
157 | 'text/plain': string;
158 | };
159 | };
160 | };
161 | };
162 | put?: never;
163 | post?: never;
164 | delete?: never;
165 | options?: never;
166 | head?: never;
167 | patch?: never;
168 | trace?: never;
169 | };
170 | '/api/failure': {
171 | parameters: {
172 | query?: never;
173 | header?: never;
174 | path?: never;
175 | cookie?: never;
176 | };
177 | /**
178 | * Failure response
179 | * @description Returns an error message, typically used as a redirect target after failed authentication
180 | */
181 | get: {
182 | parameters: {
183 | query?: never;
184 | header?: never;
185 | path?: never;
186 | cookie?: never;
187 | };
188 | requestBody?: never;
189 | responses: {
190 | /** @description Authorization failure */
191 | 500: {
192 | headers: {
193 | [name: string]: unknown;
194 | };
195 | content: {
196 | 'text/plain': string;
197 | };
198 | };
199 | };
200 | };
201 | put?: never;
202 | post?: never;
203 | delete?: never;
204 | options?: never;
205 | head?: never;
206 | patch?: never;
207 | trace?: never;
208 | };
209 | '/api/sitesSummary': {
210 | parameters: {
211 | query?: never;
212 | header?: never;
213 | path?: never;
214 | cookie?: never;
215 | };
216 | /**
217 | * Get summary metrics for all sites
218 | * @description Returns average ping, download speed, upload speed, and signal strength for each site within the specified time range
219 | */
220 | get: {
221 | parameters: {
222 | query: {
223 | /** @description Start of the time range */
224 | timeFrom: string;
225 | /** @description End of the time range */
226 | timeTo: string;
227 | };
228 | header?: never;
229 | path?: never;
230 | cookie?: never;
231 | };
232 | requestBody?: never;
233 | responses: {
234 | /** @description Summary metrics for all sites */
235 | 200: {
236 | headers: {
237 | [name: string]: unknown;
238 | };
239 | content: {
240 | 'application/json': components['schemas']['SitesSummary'];
241 | };
242 | };
243 | /** @description Bad request */
244 | 400: {
245 | headers: {
246 | [name: string]: unknown;
247 | };
248 | content?: never;
249 | };
250 | };
251 | };
252 | put?: never;
253 | post?: never;
254 | delete?: never;
255 | options?: never;
256 | head?: never;
257 | patch?: never;
258 | trace?: never;
259 | };
260 | '/api/lineSummary': {
261 | parameters: {
262 | query?: never;
263 | header?: never;
264 | path?: never;
265 | cookie?: never;
266 | };
267 | /**
268 | * Get time series data for selected sites
269 | * @description Returns time series data for the specified metric and sites within the specified time range
270 | */
271 | get: {
272 | parameters: {
273 | query: {
274 | /** @description Type of metric to aggregate (ping, download_speed, upload_speed, or dbm) */
275 | mapType: string;
276 | /** @description Comma-separated list of site names to include */
277 | selectedSites: string;
278 | /** @description Start of the time range */
279 | timeFrom: string;
280 | /** @description End of the time range */
281 | timeTo: string;
282 | };
283 | header?: never;
284 | path?: never;
285 | cookie?: never;
286 | };
287 | requestBody?: never;
288 | responses: {
289 | /** @description Time series data for selected sites */
290 | 200: {
291 | headers: {
292 | [name: string]: unknown;
293 | };
294 | content: {
295 | 'application/json': components['schemas']['LineSummaryItem'][];
296 | };
297 | };
298 | /** @description Bad request */
299 | 400: {
300 | headers: {
301 | [name: string]: unknown;
302 | };
303 | content?: never;
304 | };
305 | };
306 | };
307 | put?: never;
308 | post?: never;
309 | delete?: never;
310 | options?: never;
311 | head?: never;
312 | patch?: never;
313 | trace?: never;
314 | };
315 | '/api/markers': {
316 | parameters: {
317 | query?: never;
318 | header?: never;
319 | path?: never;
320 | cookie?: never;
321 | };
322 | /**
323 | * Get marker data for map visualization
324 | * @description Returns geolocation data with network metrics for selected sites and devices within the specified time range
325 | */
326 | get: {
327 | parameters: {
328 | query: {
329 | /** @description Comma-separated list of site names to include */
330 | sites: string;
331 | /** @description Comma-separated list of device IDs to include */
332 | devices?: string;
333 | /** @description Start of the time range */
334 | timeFrom: string;
335 | /** @description End of the time range */
336 | timeTo: string;
337 | };
338 | header?: never;
339 | path?: never;
340 | cookie?: never;
341 | };
342 | requestBody?: never;
343 | responses: {
344 | /** @description Marker data for map visualization */
345 | 200: {
346 | headers: {
347 | [name: string]: unknown;
348 | };
349 | content: {
350 | 'application/json': components['schemas']['MarkerData'][];
351 | };
352 | };
353 | /** @description Bad request */
354 | 400: {
355 | headers: {
356 | [name: string]: unknown;
357 | };
358 | content?: never;
359 | };
360 | };
361 | };
362 | put?: never;
363 | post?: never;
364 | delete?: never;
365 | options?: never;
366 | head?: never;
367 | patch?: never;
368 | trace?: never;
369 | };
370 | '/api/dataRange': {
371 | parameters: {
372 | query?: never;
373 | header?: never;
374 | path?: never;
375 | cookie?: never;
376 | };
377 | /**
378 | * Get geographic boundaries of available data
379 | * @description Returns the center coordinates and bounding box (minimum and maximum latitude/longitude) of all available measurement data.
380 | */
381 | get: {
382 | parameters: {
383 | query?: never;
384 | header?: never;
385 | path?: never;
386 | cookie?: never;
387 | };
388 | requestBody?: never;
389 | responses: {
390 | /** @description Geographic boundaries of available data */
391 | 200: {
392 | headers: {
393 | [name: string]: unknown;
394 | };
395 | content: {
396 | 'application/json': components['schemas']['DataRangeResponse'];
397 | };
398 | };
399 | /** @description Server error */
400 | 500: {
401 | headers: {
402 | [name: string]: unknown;
403 | };
404 | content: {
405 | 'text/plain': string;
406 | };
407 | };
408 | };
409 | };
410 | put?: never;
411 | post?: never;
412 | delete?: never;
413 | options?: never;
414 | head?: never;
415 | patch?: never;
416 | trace?: never;
417 | };
418 | '/api/sites': {
419 | parameters: {
420 | query?: never;
421 | header?: never;
422 | path?: never;
423 | cookie?: never;
424 | };
425 | /**
426 | * Get all sites
427 | * @description Returns a list of all available sites with their location and status information
428 | */
429 | get: operations['getSites'];
430 | put?: never;
431 | post?: never;
432 | delete?: never;
433 | options?: never;
434 | head?: never;
435 | patch?: never;
436 | trace?: never;
437 | };
438 | '/secure/get_groups': {
439 | parameters: {
440 | query?: never;
441 | header?: never;
442 | path?: never;
443 | cookie?: never;
444 | };
445 | get?: never;
446 | put?: never;
447 | /**
448 | * Get distinct group identifiers
449 | * @description Returns a list of unique group identifiers across signal and measurement data
450 | */
451 | post: {
452 | parameters: {
453 | query?: never;
454 | header?: never;
455 | path?: never;
456 | cookie?: never;
457 | };
458 | requestBody?: never;
459 | responses: {
460 | /** @description Successfully retrieved group list */
461 | 200: {
462 | headers: {
463 | [name: string]: unknown;
464 | };
465 | content: {
466 | 'application/json': components['schemas']['GroupList'];
467 | };
468 | };
469 | /** @description Unauthorized - redirects to /api/failure */
470 | 401: {
471 | headers: {
472 | [name: string]: unknown;
473 | };
474 | content?: never;
475 | };
476 | /** @description Server error */
477 | 500: {
478 | headers: {
479 | [name: string]: unknown;
480 | };
481 | content: {
482 | 'text/plain': string;
483 | };
484 | };
485 | };
486 | };
487 | delete?: never;
488 | options?: never;
489 | head?: never;
490 | patch?: never;
491 | trace?: never;
492 | };
493 | '/secure/delete_group': {
494 | parameters: {
495 | query?: never;
496 | header?: never;
497 | path?: never;
498 | cookie?: never;
499 | };
500 | get?: never;
501 | put?: never;
502 | /**
503 | * Delete a group of measurements
504 | * @description Removes all measurements associated with the specified group
505 | */
506 | post: {
507 | parameters: {
508 | query?: never;
509 | header?: never;
510 | path?: never;
511 | cookie?: never;
512 | };
513 | requestBody: {
514 | content: {
515 | 'application/json': components['schemas']['DeleteGroupRequest'];
516 | };
517 | };
518 | responses: {
519 | /** @description Successfully deleted group */
520 | 200: {
521 | headers: {
522 | [name: string]: unknown;
523 | };
524 | content: {
525 | 'text/plain': string;
526 | };
527 | };
528 | /** @description Unauthorized - redirects to /api/failure */
529 | 401: {
530 | headers: {
531 | [name: string]: unknown;
532 | };
533 | content?: never;
534 | };
535 | /** @description Server error */
536 | 500: {
537 | headers: {
538 | [name: string]: unknown;
539 | };
540 | content: {
541 | 'text/plain': string;
542 | };
543 | };
544 | };
545 | };
546 | delete?: never;
547 | options?: never;
548 | head?: never;
549 | patch?: never;
550 | trace?: never;
551 | };
552 | '/secure/delete_manual': {
553 | parameters: {
554 | query?: never;
555 | header?: never;
556 | path?: never;
557 | cookie?: never;
558 | };
559 | get?: never;
560 | put?: never;
561 | /**
562 | * Delete manual measurements
563 | * @description Removes all manually entered measurements from the database
564 | */
565 | post: {
566 | parameters: {
567 | query?: never;
568 | header?: never;
569 | path?: never;
570 | cookie?: never;
571 | };
572 | requestBody?: never;
573 | responses: {
574 | /** @description Successfully deleted manual measurements */
575 | 200: {
576 | headers: {
577 | [name: string]: unknown;
578 | };
579 | content: {
580 | 'text/plain': string;
581 | };
582 | };
583 | /** @description Unauthorized - redirects to /api/failure */
584 | 401: {
585 | headers: {
586 | [name: string]: unknown;
587 | };
588 | content?: never;
589 | };
590 | /** @description Server error */
591 | 500: {
592 | headers: {
593 | [name: string]: unknown;
594 | };
595 | content: {
596 | 'text/plain': string;
597 | };
598 | };
599 | };
600 | };
601 | delete?: never;
602 | options?: never;
603 | head?: never;
604 | patch?: never;
605 | trace?: never;
606 | };
607 | '/secure/upload_data': {
608 | parameters: {
609 | query?: never;
610 | header?: never;
611 | path?: never;
612 | cookie?: never;
613 | };
614 | get?: never;
615 | put?: never;
616 | /**
617 | * Upload and process measurement data
618 | * @description Parses CSV data and stores it as both signal and measurement records.
619 | * If a group is specified, any existing data with that group will be removed first.
620 | * The CSV should include columns for date, time, coordinate, cell_id, dbm, ping, download_speed, and upload_speed.
621 | *
622 | */
623 | post: {
624 | parameters: {
625 | query?: never;
626 | header?: never;
627 | path?: never;
628 | cookie?: never;
629 | };
630 | requestBody: {
631 | content: {
632 | 'application/json': components['schemas']['UploadDataRequest'];
633 | };
634 | };
635 | responses: {
636 | /** @description Successfully uploaded and processed data */
637 | 201: {
638 | headers: {
639 | [name: string]: unknown;
640 | };
641 | content: {
642 | 'text/plain': string;
643 | };
644 | };
645 | /** @description Bad request - missing CSV data or incorrect format */
646 | 400: {
647 | headers: {
648 | [name: string]: unknown;
649 | };
650 | content: {
651 | 'text/plain': string;
652 | };
653 | };
654 | /** @description Unauthorized - redirects to /api/failure */
655 | 401: {
656 | headers: {
657 | [name: string]: unknown;
658 | };
659 | content?: never;
660 | };
661 | /** @description Server error during processing */
662 | 500: {
663 | headers: {
664 | [name: string]: unknown;
665 | };
666 | content: {
667 | 'text/plain': string;
668 | };
669 | };
670 | /** @description Service unavailable - database operation failed */
671 | 503: {
672 | headers: {
673 | [name: string]: unknown;
674 | };
675 | content: {
676 | 'text/plain': string;
677 | };
678 | };
679 | };
680 | };
681 | delete?: never;
682 | options?: never;
683 | head?: never;
684 | patch?: never;
685 | trace?: never;
686 | };
687 | '/secure/get-users': {
688 | parameters: {
689 | query?: never;
690 | header?: never;
691 | path?: never;
692 | cookie?: never;
693 | };
694 | get?: never;
695 | put?: never;
696 | /**
697 | * Get lists of registered and pending users
698 | * @description Returns two lists:
699 | * 1. Registered users sorted by issue date (newest first)
700 | * 2. Pending users whose issue date is within the expiry display limit, sorted by issue date (newest first)
701 | *
702 | */
703 | post: {
704 | parameters: {
705 | query?: never;
706 | header?: never;
707 | path?: never;
708 | cookie?: never;
709 | };
710 | requestBody?: never;
711 | responses: {
712 | /** @description Successfully retrieved user lists */
713 | 200: {
714 | headers: {
715 | [name: string]: unknown;
716 | };
717 | content: {
718 | 'application/json': components['schemas']['GetUserResponse'];
719 | };
720 | };
721 | /** @description Unauthorized - redirects to /api/failure */
722 | 401: {
723 | headers: {
724 | [name: string]: unknown;
725 | };
726 | content?: never;
727 | };
728 | /** @description Server error */
729 | 500: {
730 | headers: {
731 | [name: string]: unknown;
732 | };
733 | content: {
734 | 'text/plain': string;
735 | };
736 | };
737 | };
738 | };
739 | delete?: never;
740 | options?: never;
741 | head?: never;
742 | patch?: never;
743 | trace?: never;
744 | };
745 | '/secure/toggle-users': {
746 | parameters: {
747 | query?: never;
748 | header?: never;
749 | path?: never;
750 | cookie?: never;
751 | };
752 | get?: never;
753 | put?: never;
754 | /**
755 | * Toggle a user's enabled status
756 | * @description Enables or disables a user account by their identity
757 | */
758 | post: {
759 | parameters: {
760 | query?: never;
761 | header?: never;
762 | path?: never;
763 | cookie?: never;
764 | };
765 | requestBody: {
766 | content: {
767 | 'application/json': components['schemas']['ToggleUserRequest'];
768 | };
769 | };
770 | responses: {
771 | /** @description Successfully toggled user status */
772 | 201: {
773 | headers: {
774 | [name: string]: unknown;
775 | };
776 | content: {
777 | 'text/plain': string;
778 | };
779 | };
780 | /** @description Bad request - missing or invalid parameters */
781 | 400: {
782 | headers: {
783 | [name: string]: unknown;
784 | };
785 | content: {
786 | 'text/plain': string;
787 | };
788 | };
789 | /** @description Unauthorized - redirects to /api/failure */
790 | 401: {
791 | headers: {
792 | [name: string]: unknown;
793 | };
794 | content?: never;
795 | };
796 | /** @description Service unavailable - database operation failed */
797 | 503: {
798 | headers: {
799 | [name: string]: unknown;
800 | };
801 | content: {
802 | 'text/plain': string;
803 | };
804 | };
805 | };
806 | };
807 | delete?: never;
808 | options?: never;
809 | head?: never;
810 | patch?: never;
811 | trace?: never;
812 | };
813 | '/secure/login': {
814 | parameters: {
815 | query?: never;
816 | header?: never;
817 | path?: never;
818 | cookie?: never;
819 | };
820 | get?: never;
821 | put?: never;
822 | /**
823 | * LDAP authentication
824 | * @description Authenticates a user against an LDAP directory server.
825 | * Uses Passport LDAP strategy which binds to the LDAP server with the provided credentials.
826 | * On success, creates a session and redirects to /api/success.
827 | * On failure, redirects to /api/failure.
828 | *
829 | */
830 | post: {
831 | parameters: {
832 | query?: never;
833 | header?: never;
834 | path?: never;
835 | cookie?: never;
836 | };
837 | requestBody: {
838 | content: {
839 | 'application/json': components['schemas']['LdapCredentials'];
840 | 'application/x-www-form-urlencoded': components['schemas']['LdapCredentials'];
841 | };
842 | };
843 | responses: {
844 | /** @description Successful authentication */
845 | 200: {
846 | headers: {
847 | [name: string]: unknown;
848 | };
849 | content: {
850 | 'application/json': {
851 | /** @example success */
852 | result: string;
853 | };
854 | };
855 | };
856 | /** @description Authentication failed */
857 | 401: {
858 | headers: {
859 | [name: string]: unknown;
860 | };
861 | content: {
862 | 'application/json': {
863 | /** @example Invalid credentials */
864 | error: string;
865 | };
866 | };
867 | };
868 | /** @description Server error */
869 | 500: {
870 | headers: {
871 | [name: string]: unknown;
872 | };
873 | content: {
874 | 'application/json': {
875 | /** @example Failed to establish session */
876 | error: string;
877 | };
878 | };
879 | };
880 | };
881 | };
882 | delete?: never;
883 | options?: never;
884 | head?: never;
885 | patch?: never;
886 | trace?: never;
887 | };
888 | '/api/logout': {
889 | parameters: {
890 | query?: never;
891 | header?: never;
892 | path?: never;
893 | cookie?: never;
894 | };
895 | /**
896 | * Log out user
897 | * @description Ends the user's authenticated session
898 | */
899 | get: {
900 | parameters: {
901 | query?: never;
902 | header?: never;
903 | path?: never;
904 | cookie?: never;
905 | };
906 | requestBody?: never;
907 | responses: {
908 | /** @description Successfully logged out */
909 | 200: {
910 | headers: {
911 | [name: string]: unknown;
912 | };
913 | content: {
914 | 'text/plain': string;
915 | };
916 | };
917 | };
918 | };
919 | put?: never;
920 | post?: never;
921 | delete?: never;
922 | options?: never;
923 | head?: never;
924 | patch?: never;
925 | trace?: never;
926 | };
927 | '/secure/edit_sites': {
928 | parameters: {
929 | query?: never;
930 | header?: never;
931 | path?: never;
932 | cookie?: never;
933 | };
934 | get?: never;
935 | put?: never;
936 | /**
937 | * Update site configuration
938 | * @description Updates the sites configuration file with provided data.
939 | * Requires user to be authenticated - will redirect to login page if not authenticated.
940 | *
941 | */
942 | post: {
943 | parameters: {
944 | query?: never;
945 | header?: never;
946 | path?: never;
947 | cookie?: never;
948 | };
949 | requestBody: {
950 | content: {
951 | 'application/x-www-form-urlencoded': {
952 | /**
953 | * @description JSON string containing site configuration data
954 | * @example {"site1":{"name":"Site 1","location":"Building A"},"site2":{"name":"Site 2","location":"Building B"}}
955 | */
956 | sites: string;
957 | };
958 | };
959 | };
960 | responses: {
961 | /** @description Site configuration successfully updated */
962 | 201: {
963 | headers: {
964 | [name: string]: unknown;
965 | };
966 | content: {
967 | 'text/plain': string;
968 | };
969 | };
970 | /** @description Bad request - invalid JSON format or missing required fields */
971 | 400: {
972 | headers: {
973 | [name: string]: unknown;
974 | };
975 | content: {
976 | 'text/plain': string;
977 | };
978 | };
979 | /** @description Unauthorized - User not logged in */
980 | 401: {
981 | headers: {
982 | [name: string]: unknown;
983 | };
984 | content: {
985 | 'text/plain': string;
986 | };
987 | };
988 | /** @description Server error while updating configuration file */
989 | 500: {
990 | headers: {
991 | [name: string]: unknown;
992 | };
993 | content: {
994 | 'text/plain': string;
995 | };
996 | };
997 | };
998 | };
999 | delete?: never;
1000 | options?: never;
1001 | head?: never;
1002 | patch?: never;
1003 | trace?: never;
1004 | };
1005 | '/secure/new-user': {
1006 | parameters: {
1007 | query?: never;
1008 | header?: never;
1009 | path?: never;
1010 | cookie?: never;
1011 | };
1012 | get?: never;
1013 | put?: never;
1014 | /**
1015 | * Create a new user with cryptographic identity
1016 | * @description Creates a new user with a cryptographically secure identity using EC keys.
1017 | * Generates keypairs, creates signatures, and stores user information.
1018 | * Requires authentication - will redirect to login page if not authenticated.
1019 | *
1020 | */
1021 | post: {
1022 | parameters: {
1023 | query?: never;
1024 | header?: never;
1025 | path?: never;
1026 | cookie?: never;
1027 | };
1028 | requestBody: {
1029 | content: {
1030 | 'application/json': components['schemas']['NewUserRequest'];
1031 | };
1032 | };
1033 | responses: {
1034 | /** @description User successfully created with cryptographic identity */
1035 | 201: {
1036 | headers: {
1037 | [name: string]: unknown;
1038 | };
1039 | content: {
1040 | 'application/json': components['schemas']['NewUserRequest'];
1041 | };
1042 | };
1043 | /** @description Unauthorized - User not logged in */
1044 | 401: {
1045 | headers: {
1046 | [name: string]: unknown;
1047 | };
1048 | content: {
1049 | 'text/plain': string;
1050 | };
1051 | };
1052 | /** @description Cryptographic operation error */
1053 | 500: {
1054 | headers: {
1055 | [name: string]: unknown;
1056 | };
1057 | content: {
1058 | 'text/plain': string;
1059 | };
1060 | };
1061 | /** @description Database operation error */
1062 | 503: {
1063 | headers: {
1064 | [name: string]: unknown;
1065 | };
1066 | content: {
1067 | 'text/plain': string;
1068 | };
1069 | };
1070 | };
1071 | };
1072 | delete?: never;
1073 | options?: never;
1074 | head?: never;
1075 | patch?: never;
1076 | trace?: never;
1077 | };
1078 | }
1079 | export type webhooks = Record;
1080 | export interface components {
1081 | schemas: {
1082 | NewUserRequest: {
1083 | /**
1084 | * Format: email
1085 | * @description User's email address
1086 | * @example user@example.com
1087 | */
1088 | email?: string;
1089 | /**
1090 | * @description User's first name
1091 | * @example John
1092 | */
1093 | firstName?: string;
1094 | /**
1095 | * @description User's last name
1096 | * @example Doe
1097 | */
1098 | lastName?: string;
1099 | };
1100 | LdapCredentials: {
1101 | /**
1102 | * @description LDAP username (could be DN, uid, or email depending on LDAP configuration)
1103 | * @example uid=jsmith,ou=users,dc=example,dc=com
1104 | */
1105 | username: string;
1106 | /**
1107 | * Format: password
1108 | * @description LDAP password
1109 | * @example password123
1110 | */
1111 | password: string;
1112 | };
1113 | CryptoIdentityResponse: {
1114 | /**
1115 | * @description Cryptographic signature in hexadecimal format
1116 | * @example 30450221009d41a9afd...
1117 | */
1118 | sigma_t?: string;
1119 | /**
1120 | * @description Private key in DER format converted to hexadecimal
1121 | * @example 308184020100301006...
1122 | */
1123 | sk_t?: string;
1124 | /**
1125 | * @description Public key in DER format converted to hexadecimal
1126 | * @example 3056301006072a8648...
1127 | */
1128 | pk_a?: string;
1129 | };
1130 | UploadDataRequest: {
1131 | /**
1132 | * @description CSV data to be parsed and stored
1133 | * @example date,time,coordinate,cell_id,dbm,ping,download_speed,upload_speed
1134 | * 2021-01-25,18:43:54,47.681932,-122.318292,cell-1,-85.3,-87.1,137.4,5.2,7.3
1135 | */
1136 | csv: string;
1137 | /**
1138 | * @description Optional group identifier to associate with uploaded data
1139 | * @example fieldtrip-2021
1140 | */
1141 | group?: string;
1142 | };
1143 | DeleteGroupRequest: {
1144 | /**
1145 | * @description Group identifier to delete
1146 | * @example fieldtrip-2021
1147 | */
1148 | group: string;
1149 | };
1150 | /** @description List of unique group identifiers across signal and measurement data */
1151 | GroupList: string[];
1152 | User: {
1153 | /**
1154 | * @description Unique identifier for the user
1155 | * @example 9a8b7c6d5e4f3g2h1i
1156 | */
1157 | identity: string;
1158 | /**
1159 | * Format: email
1160 | * @description User's email address
1161 | * @example user@example.com
1162 | */
1163 | email: string;
1164 | /**
1165 | * @description User's first name
1166 | * @example John
1167 | */
1168 | firstName: string;
1169 | /**
1170 | * @description User's last name
1171 | * @example Doe
1172 | */
1173 | lastName: string;
1174 | /**
1175 | * @description Whether the user has completed registration
1176 | * @example true
1177 | */
1178 | registered: boolean;
1179 | /**
1180 | * Format: date-time
1181 | * @description Date when the user was issued or account was created
1182 | * @example 2023-03-15T14:30:45.123Z
1183 | */
1184 | issueDate: string;
1185 | /**
1186 | * @description Whether the user account is currently enabled
1187 | * @example true
1188 | */
1189 | isEnabled: boolean;
1190 | /**
1191 | * @description User's public key
1192 | * @example 308201a2300d06092a864886f70d01010105000382018f003082018a02820181...
1193 | */
1194 | publicKey: string;
1195 | /**
1196 | * @description QR code data for user registration
1197 | * @example {"sigma_t":"...","sk_t":"...","pk_a":"..."}
1198 | */
1199 | qrCode: string;
1200 | /**
1201 | * Format: date-time
1202 | * @description Last time the user was online in ISO 8601 date-time (YYYY-MM-DDTHH:mm:ss.sssZ)
1203 | * @example 2023-03-15T14:30:45.123Z
1204 | */
1205 | lastOnline: string;
1206 | };
1207 | GetUserResponse: {
1208 | /** @description List of pending users (not yet registered) whose issue date is within the expiry limit */
1209 | pending: components['schemas']['User'][];
1210 | /** @description List of registered users */
1211 | registered: components['schemas']['User'][];
1212 | };
1213 | ToggleUserRequest: {
1214 | /**
1215 | * @description Unique identifier of the user to update
1216 | * @example 9a8b7c6d5e4f3g2h1i
1217 | */
1218 | identity: string;
1219 | /**
1220 | * @description New enabled status for the user
1221 | * @example true
1222 | */
1223 | enabled: boolean;
1224 | };
1225 | BaseMeasureDataModel: {
1226 | /**
1227 | * Format: double
1228 | * @description Geographic latitude coordinate
1229 | */
1230 | latitude: number;
1231 | /**
1232 | * Format: double
1233 | * @description Geographic longitude coordinate
1234 | */
1235 | longitude: number;
1236 | /** @description When the measurement was taken */
1237 | timestamp: string;
1238 | /** @description Identifier for the cell tower or access point */
1239 | cell_id: string;
1240 | /** @description Unique identifier for the reporting device */
1241 | device_id: string;
1242 | /** @description Flag indicating if the measurement has been reported and will be shown on the map. */
1243 | show_data: boolean;
1244 | };
1245 | SignalStrengthReportModel: components['schemas']['BaseMeasureDataModel'] & {
1246 | /** @description Signal strength in decibel-milliwatts */
1247 | dbm: number;
1248 | /** @description Code representing the signal strength level */
1249 | level_code: number;
1250 | };
1251 | ConnectivityReportModel: components['schemas']['BaseMeasureDataModel'] & {
1252 | /**
1253 | * Format: double
1254 | * @description Upload speed measurement
1255 | */
1256 | upload_speed: number;
1257 | /**
1258 | * Format: double
1259 | * @description Download speed measurement
1260 | */
1261 | download_speed: number;
1262 | /**
1263 | * Format: double
1264 | * @description Network latency in milliseconds
1265 | */
1266 | ping: number;
1267 | /**
1268 | * Format: double
1269 | * @description Packet loss percentage
1270 | */
1271 | package_loss: number;
1272 | };
1273 | Site: {
1274 | /**
1275 | * @description Name of the site
1276 | * @example Filipino Community Center
1277 | */
1278 | name: string;
1279 | /**
1280 | * Format: double
1281 | * @description Geographic latitude coordinate
1282 | * @example 47.681932654395915
1283 | */
1284 | latitude: number;
1285 | /**
1286 | * Format: double
1287 | * @description Geographic longitude coordinate
1288 | * @example -122.31829217664796
1289 | */
1290 | longitude: number;
1291 | /**
1292 | * @description Current status of the site
1293 | * @example active
1294 | * @enum {string}
1295 | */
1296 | status: SiteStatus;
1297 | /**
1298 | * @description Physical address of the site
1299 | * @example 5740 Martin Luther King Jr Way S, Seattle, WA 98118
1300 | */
1301 | address: string;
1302 | /** @description Array of cell identifiers associated with the site */
1303 | cell_id: string[];
1304 | /**
1305 | * @description Optional color identifier for the site in hex code
1306 | * @example #FF5733
1307 | */
1308 | color?: string;
1309 | /** @description Optional geographical boundary coordinates defining the site perimeter as [latitude, longitude] pairs */
1310 | boundary?: [number, number][];
1311 | };
1312 | /** @example {
1313 | * "Filipino Community Center": {
1314 | * "ping": 115.28,
1315 | * "download_speed": 7.16,
1316 | * "upload_speed": 8.63,
1317 | * "dbm": -78.4
1318 | * }
1319 | * } */
1320 | SitesSummary: {
1321 | [key: string]: {
1322 | /**
1323 | * Format: double
1324 | * @description Average ping in milliseconds
1325 | * @example 137.41
1326 | */
1327 | ping: number;
1328 | /**
1329 | * Format: double
1330 | * @description Average download speed
1331 | * @example 5.23
1332 | */
1333 | download_speed: number;
1334 | /**
1335 | * Format: double
1336 | * @description Average upload speed
1337 | * @example 7.28
1338 | */
1339 | upload_speed: number;
1340 | /**
1341 | * Format: double
1342 | * @description Average signal strength in dBm
1343 | * @example -85.3
1344 | */
1345 | dbm: number;
1346 | };
1347 | };
1348 | MarkerData: {
1349 | /**
1350 | * Format: double
1351 | * @description Geographic latitude coordinate
1352 | * @example 47.681932654395915
1353 | */
1354 | latitude: number;
1355 | /**
1356 | * Format: double
1357 | * @description Geographic longitude coordinate
1358 | * @example -122.31829217664796
1359 | */
1360 | longitude: number;
1361 | /**
1362 | * @description Identifier for the device that collected the data
1363 | * @example 1e683a49d71ffd0
1364 | */
1365 | device_id: string;
1366 | /**
1367 | * @description Name of the site
1368 | * @example Filipino Community Center
1369 | */
1370 | site: string;
1371 | /**
1372 | * Format: double
1373 | * @description Signal strength in dBm (optional)
1374 | * @example -78.4
1375 | */
1376 | dbm?: number;
1377 | /**
1378 | * Format: double
1379 | * @description Upload speed measurement
1380 | * @example 7.28
1381 | */
1382 | upload_speed: number;
1383 | /**
1384 | * Format: double
1385 | * @description Download speed measurement
1386 | * @example 5.23
1387 | */
1388 | download_speed: number;
1389 | /**
1390 | * Format: double
1391 | * @description Network latency measurement
1392 | * @example 137.41
1393 | */
1394 | ping: number;
1395 | /**
1396 | * @description Measurement identifier
1397 | * @example 614157263c28e1a473ede843
1398 | */
1399 | mid: string;
1400 | };
1401 | LineSummaryItem: {
1402 | /**
1403 | * @description Name of the site
1404 | * @example Filipino Community Center
1405 | */
1406 | site: string;
1407 | /** @description Time series data points for the site */
1408 | values: {
1409 | /**
1410 | * Format: date-time
1411 | * @description Timestamp for the data point
1412 | * @example 2021-01-25T18:00:00.000Z
1413 | */
1414 | date: string;
1415 | /**
1416 | * Format: double
1417 | * @description Average value for the metric at this timestamp
1418 | * @example 7.28
1419 | */
1420 | value: number;
1421 | }[];
1422 | };
1423 | UserRegistration: {
1424 | /**
1425 | * @description Public key in hexadecimal format, ed25519, pem, pkcs8.
1426 | * @example aabbccddeeff00112233445566778899
1427 | */
1428 | publicKey: string;
1429 | /**
1430 | * Format: byte
1431 | * @description Message to be registered in binary format.
1432 | * @example SGVsbG8gd29ybGQ=
1433 | */
1434 | message: string;
1435 | /**
1436 | * @description Signature of message in hex.
1437 | * @example c3lzdGVtZXN0cmluZw==
1438 | */
1439 | sigMessage: string;
1440 | };
1441 | DataReport: {
1442 | /** @description The parameter `h_pkr` */
1443 | h_pkr: string;
1444 | /** @description The parameter `sigma_m` */
1445 | sigma_m: string;
1446 | /** @description The parameter `M` */
1447 | M: string;
1448 | };
1449 | SiteMeasurementData: {
1450 | /**
1451 | * @description Unique identifier for the record
1452 | * @example 614157263c28e1a473ede843
1453 | */
1454 | _id: string;
1455 | /**
1456 | * Format: double
1457 | * @description Geographic latitude coordinate
1458 | * @example 47.681932654395915
1459 | */
1460 | latitude: number;
1461 | /**
1462 | * Format: double
1463 | * @description Geographic longitude coordinate
1464 | * @example -122.31829217664796
1465 | */
1466 | longitude: number;
1467 | /**
1468 | * Format: date-time
1469 | * @description Time when the measurement was recorded
1470 | * @example 2021-01-25T18:43:54.370Z
1471 | */
1472 | timestamp: string;
1473 | /**
1474 | * Format: double
1475 | * @description Upload speed measurement
1476 | * @example 7.289173724717997
1477 | */
1478 | upload_speed: number;
1479 | /**
1480 | * Format: double
1481 | * @description Download speed measurement
1482 | * @example 5.234371563131994
1483 | */
1484 | download_speed: number;
1485 | /**
1486 | * Format: double
1487 | * @description Amount of data transferred since the previous report
1488 | * @example 735.2343217314725
1489 | */
1490 | data_since_last_report: number;
1491 | /**
1492 | * Format: double
1493 | * @description Network latency measurement
1494 | * @example 137.41470114174285
1495 | */
1496 | ping: number;
1497 | /**
1498 | * @description Identifier for the cell/location where data was collected
1499 | * @example Filipino Community Center
1500 | */
1501 | cell_id: string;
1502 | /**
1503 | * @description Identifier for the device that collected the data
1504 | * @example 1e683a49d71ffd0
1505 | */
1506 | device_id: string;
1507 | };
1508 | DataRangeResponse: {
1509 | /**
1510 | * @description Center coordinates [latitude, longitude] of the data range
1511 | * @example [
1512 | * 47.6062,
1513 | * -122.3321
1514 | * ]
1515 | */
1516 | center: number[];
1517 | /**
1518 | * Format: double
1519 | * @description Minimum latitude value in the data range
1520 | * @example 47.5001
1521 | */
1522 | minLat: number;
1523 | /**
1524 | * Format: double
1525 | * @description Minimum longitude value in the data range
1526 | * @example -122.4382
1527 | */
1528 | minLon: number;
1529 | /**
1530 | * Format: double
1531 | * @description Maximum latitude value in the data range
1532 | * @example 47.734
1533 | */
1534 | maxLat: number;
1535 | /**
1536 | * Format: double
1537 | * @description Maximum longitude value in the data range
1538 | * @example -122.2364
1539 | */
1540 | maxLon: number;
1541 | };
1542 | /** @description Represents a single data point in a query result */
1543 | QueryData: {
1544 | /**
1545 | * @description The bin/bucket identifier for data categorization
1546 | * @example 3
1547 | */
1548 | bin: number;
1549 | /**
1550 | * @description The string representation of calculated average value for data in this bin with 2 decimal places.
1551 | * @example 45.70
1552 | */
1553 | average: string;
1554 | };
1555 | };
1556 | responses: never;
1557 | parameters: never;
1558 | requestBodies: never;
1559 | headers: never;
1560 | pathItems: never;
1561 | }
1562 | export type $defs = Record;
1563 | export interface operations {
1564 | registerUser: {
1565 | parameters: {
1566 | query?: never;
1567 | header?: never;
1568 | path?: never;
1569 | cookie?: never;
1570 | };
1571 | requestBody: {
1572 | content: {
1573 | 'application/json': components['schemas']['UserRegistration'];
1574 | };
1575 | };
1576 | responses: {
1577 | /** @description User already registered */
1578 | 200: {
1579 | headers: {
1580 | [name: string]: unknown;
1581 | };
1582 | content?: never;
1583 | };
1584 | /** @description User registered successfully */
1585 | 201: {
1586 | headers: {
1587 | [name: string]: unknown;
1588 | };
1589 | content?: never;
1590 | };
1591 | /** @description Invalid input */
1592 | 400: {
1593 | headers: {
1594 | [name: string]: unknown;
1595 | };
1596 | content?: never;
1597 | };
1598 | /** @description Unauthorized registration */
1599 | 401: {
1600 | headers: {
1601 | [name: string]: unknown;
1602 | };
1603 | content?: never;
1604 | };
1605 | /** @description Registration keys are invalid */
1606 | 403: {
1607 | headers: {
1608 | [name: string]: unknown;
1609 | };
1610 | content?: never;
1611 | };
1612 | /** @description Registration period expired */
1613 | 408: {
1614 | headers: {
1615 | [name: string]: unknown;
1616 | };
1617 | content?: never;
1618 | };
1619 | /** @description Internal server error */
1620 | 503: {
1621 | headers: {
1622 | [name: string]: unknown;
1623 | };
1624 | content?: never;
1625 | };
1626 | };
1627 | };
1628 | reportSignal: {
1629 | parameters: {
1630 | query?: never;
1631 | header?: never;
1632 | path?: never;
1633 | cookie?: never;
1634 | };
1635 | requestBody: {
1636 | content: {
1637 | 'application/json': components['schemas']['DataReport'];
1638 | };
1639 | };
1640 | responses: {
1641 | /** @description Signal reported successfully */
1642 | 201: {
1643 | headers: {
1644 | [name: string]: unknown;
1645 | };
1646 | content?: never;
1647 | };
1648 | /** @description User not found */
1649 | 401: {
1650 | headers: {
1651 | [name: string]: unknown;
1652 | };
1653 | content?: never;
1654 | };
1655 | /** @description Invalid signature */
1656 | 403: {
1657 | headers: {
1658 | [name: string]: unknown;
1659 | };
1660 | content?: never;
1661 | };
1662 | /** @description Internal server error */
1663 | 500: {
1664 | headers: {
1665 | [name: string]: unknown;
1666 | };
1667 | content?: never;
1668 | };
1669 | };
1670 | };
1671 | reportMeasurement: {
1672 | parameters: {
1673 | query?: never;
1674 | header?: never;
1675 | path?: never;
1676 | cookie?: never;
1677 | };
1678 | requestBody: {
1679 | content: {
1680 | 'application/json': components['schemas']['DataReport'];
1681 | };
1682 | };
1683 | responses: {
1684 | /** @description Signal reported successfully */
1685 | 201: {
1686 | headers: {
1687 | [name: string]: unknown;
1688 | };
1689 | content?: never;
1690 | };
1691 | /** @description User not found */
1692 | 401: {
1693 | headers: {
1694 | [name: string]: unknown;
1695 | };
1696 | content?: never;
1697 | };
1698 | /** @description Invalid signature */
1699 | 403: {
1700 | headers: {
1701 | [name: string]: unknown;
1702 | };
1703 | content?: never;
1704 | };
1705 | /** @description Internal server error */
1706 | 500: {
1707 | headers: {
1708 | [name: string]: unknown;
1709 | };
1710 | content?: never;
1711 | };
1712 | };
1713 | };
1714 | getSites: {
1715 | parameters: {
1716 | query?: never;
1717 | header?: never;
1718 | path?: never;
1719 | cookie?: never;
1720 | };
1721 | requestBody?: never;
1722 | responses: {
1723 | /** @description List of sites */
1724 | 200: {
1725 | headers: {
1726 | [name: string]: unknown;
1727 | };
1728 | content: {
1729 | 'application/json': components['schemas']['Site'][];
1730 | };
1731 | };
1732 | };
1733 | };
1734 | }
1735 | export enum SiteStatus {
1736 | active = 'active',
1737 | confirmed = 'confirmed',
1738 | in_conversation = 'in-conversation',
1739 | }
1740 |
--------------------------------------------------------------------------------