diff --git a/README.md b/README.md index 854146a..2ee653b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ git clone https://github.com/liamcottle/meshtastic-map cd meshtastic-map ``` +Install Meshtastic protobufs definitions +``` +git clone https://github.com/meshtastic/protobufs src/protobufs +``` + Install NodeJS dependencies ``` diff --git a/package-lock.json b/package-lock.json index 2cbfa9b..1fb2318 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ }, "devDependencies": { "jest": "^30.1.3", - "prisma": "^7.4.1" + "prisma": "^6.16.2" } }, "node_modules/@ampproject/remapping": { @@ -69,6 +69,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -603,73 +604,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", - "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/gast": "10.5.0", - "@chevrotain/types": "10.5.0", - "lodash": "4.17.21" - } - }, - "node_modules/@chevrotain/gast": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", - "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/types": "10.5.0", - "lodash": "4.17.21" - } - }, - "node_modules/@chevrotain/types": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", - "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@chevrotain/utils": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", - "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@electric-sql/pglite": { - "version": "0.3.15", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", - "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@electric-sql/pglite-socket": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", - "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "pglite-server": "dist/scripts/server.js" - }, - "peerDependencies": { - "@electric-sql/pglite": "0.3.15" - } - }, - "node_modules/@electric-sql/pglite-tools": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", - "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", - "devOptional": true, - "license": "Apache-2.0", - "peerDependencies": { - "@electric-sql/pglite": "0.3.15" - } - }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -704,19 +638,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1143,20 +1064,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mrleebo/prisma-ast": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", - "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chevrotain": "^10.5.0", - "lilconfig": "^2.1.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1217,138 +1124,66 @@ } }, "node_modules/@prisma/config": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.1.tgz", - "integrity": "sha512-vteSXm8N46bo3FW9MhPGVHAj+KRgrR6TWtlSk6GqToCKjTnOexXdPZyiDyEsfVW38YhqEmVl6w/6iHN8uYVJcw==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.2.tgz", + "integrity": "sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.18.4", + "effect": "3.16.12", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.1.tgz", - "integrity": "sha512-qEtzO8oLouRv18JDQUC3G3Gnv+fGVscHZm/x1DBB/WT+kOvPDQLM2woX6IGgWnSMYYlrxjuALshT7G/blvY0bQ==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz", + "integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==", "devOptional": true, "license": "Apache-2.0" }, - "node_modules/@prisma/dev": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", - "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", - "devOptional": true, - "license": "ISC", - "dependencies": { - "@electric-sql/pglite": "0.3.15", - "@electric-sql/pglite-socket": "0.0.20", - "@electric-sql/pglite-tools": "0.2.20", - "@hono/node-server": "1.19.9", - "@mrleebo/prisma-ast": "0.13.1", - "@prisma/get-platform": "7.2.0", - "@prisma/query-plan-executor": "7.2.0", - "foreground-child": "3.3.1", - "get-port-please": "3.2.0", - "hono": "4.11.4", - "http-status-codes": "2.3.0", - "pathe": "2.0.3", - "proper-lockfile": "4.1.2", - "remeda": "2.33.4", - "std-env": "3.10.0", - "valibot": "1.2.0", - "zeptomatch": "2.1.0" - } - }, "node_modules/@prisma/engines": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.1.tgz", - "integrity": "sha512-BZEBdHvNJx5PzIG37EI/Zi5UUI5hGWjkYsQmKa7OIK6evAvebOTwutjS/VRI6cA6grmA52eLZR+oekGRMqkKxQ==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.2.tgz", + "integrity": "sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.4.1", - "@prisma/engines-version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", - "@prisma/fetch-engine": "7.4.1", - "@prisma/get-platform": "7.4.1" + "@prisma/debug": "6.16.2", + "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", + "@prisma/fetch-engine": "6.16.2", + "@prisma/get-platform": "6.16.2" } }, "node_modules/@prisma/engines-version": { - "version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3.tgz", - "integrity": "sha512-fUxVd1TjOW8K4XsZ8dAm88sDW5Ry7AxWDfsYEWwScS6Fjo3caKC6hgNumUfsmsy0Il9LjDn5X0PpVXNt3iwayw==", + "version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz", + "integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==", "devOptional": true, "license": "Apache-2.0" }, - "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.1.tgz", - "integrity": "sha512-kN4tmkQzlgm/KtE+jTNSYjsDxxe/5i6GApPI32BN9T0tlgsgSBtDJbjGBICttkAIjsh73dXf8raPKxO/2n2UUg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.4.1" - } - }, "node_modules/@prisma/fetch-engine": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.1.tgz", - "integrity": "sha512-Z9kbuxX2bvEsyeS3LZEiEnxG0lVtZbpYgaAnPj69N+A9f2De8Lta0EoFtld9zhfERVPIQWhSWUc8himky3qYdA==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.2.tgz", + "integrity": "sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.4.1", - "@prisma/engines-version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", - "@prisma/get-platform": "7.4.1" - } - }, - "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.1.tgz", - "integrity": "sha512-kN4tmkQzlgm/KtE+jTNSYjsDxxe/5i6GApPI32BN9T0tlgsgSBtDJbjGBICttkAIjsh73dXf8raPKxO/2n2UUg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "7.4.1" + "@prisma/debug": "6.16.2", + "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", + "@prisma/get-platform": "6.16.2" } }, "node_modules/@prisma/get-platform": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", - "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.2.tgz", + "integrity": "sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.2.0" - } - }, - "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", - "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/query-plan-executor": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", - "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/studio-core": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", - "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", - "devOptional": true, - "license": "Apache-2.0", - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "@prisma/debug": "6.16.2" } }, "node_modules/@protobufjs/aspromise": { @@ -1433,9 +1268,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "devOptional": true, "license": "MIT" }, @@ -1530,17 +1365,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.2.2" - } - }, "node_modules/@types/readable-stream": { "version": "4.0.21", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.21.tgz", @@ -1947,16 +1771,6 @@ "node": ">=12.17" } }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/babel-jest": { "version": "30.1.2", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.2.tgz", @@ -2196,6 +2010,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -2395,21 +2210,6 @@ "node": ">=10" } }, - "node_modules/chevrotain": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", - "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/cst-dts-gen": "10.5.0", - "@chevrotain/gast": "10.5.0", - "@chevrotain/types": "10.5.0", - "@chevrotain/utils": "10.5.0", - "lodash": "4.17.21", - "regexp-to-ast": "0.5.0" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2681,9 +2481,9 @@ } }, "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "devOptional": true, "license": "MIT" }, @@ -2756,7 +2556,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2767,14 +2567,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT", - "peer": true - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2825,16 +2617,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2900,9 +2682,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "version": "3.16.12", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz", + "integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3204,9 +2986,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", "devOptional": true, "license": "MIT" }, @@ -3365,7 +3147,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -3425,16 +3207,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "is-property": "^1.0.2" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3489,13 +3261,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-port-please": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", - "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "devOptional": true, - "license": "MIT" - }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3577,23 +3342,9 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, - "node_modules/grammex": { - "version": "3.1.12", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", - "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/graphmatch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", - "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", - "devOptional": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3632,16 +3383,6 @@ "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "license": "MIT" }, - "node_modules/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3669,13 +3410,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-status-codes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "devOptional": true, - "license": "MIT" - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -3838,13 +3572,6 @@ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "devOptional": true, - "license": "MIT" - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3862,7 +3589,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4597,9 +4324,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "devOptional": true, "license": "MIT", "bin": { @@ -4686,16 +4413,6 @@ "node": ">=6" } }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4716,13 +4433,6 @@ "node": ">=8" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "devOptional": true, - "license": "MIT" - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -4739,22 +4449,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/lru.min": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", - "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", - "devOptional": true, - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -5008,40 +4702,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/mysql2": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", - "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.1", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "lru.min": "^1.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/napi-postinstall": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", @@ -5152,30 +4812,25 @@ "license": "MIT" }, "node_modules/nypm": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", - "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", "devOptional": true, "license": "MIT", "dependencies": { - "citty": "^0.2.0", + "citty": "^0.1.6", + "consola": "^3.4.2", "pathe": "^2.0.3", - "tinyexec": "^1.0.2" + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" }, "engines": { - "node": ">=18" + "node": "^14.16.0 || >=16.10.0" } }, - "node_modules/nypm/node_modules/citty": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", - "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", - "devOptional": true, - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5360,7 +5015,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5460,20 +5115,6 @@ "pathe": "^2.0.3" } }, - "node_modules/postgres": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", - "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "devOptional": true, - "license": "Unlicense", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/porsager" - } - }, "node_modules/pretty-format": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", @@ -5503,34 +5144,27 @@ } }, "node_modules/prisma": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.1.tgz", - "integrity": "sha512-gDKOXwnPiMdB+uYMhMeN8jj4K7Cu3Q2wB/wUsITOoOk446HtVb8T9BZxFJ1Zop6alc89k6PMNdR2FZCpbXp/jw==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz", + "integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { - "@prisma/config": "7.4.1", - "@prisma/dev": "0.20.0", - "@prisma/engines": "7.4.1", - "@prisma/studio-core": "0.13.1", - "mysql2": "3.15.3", - "postgres": "3.4.7" + "@prisma/config": "6.16.2", + "@prisma/engines": "6.16.2" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": "^20.19 || ^22.12 || >=24.0" + "node": ">=18.18" }, "peerDependencies": { - "better-sqlite3": ">=9.0.0", - "typescript": ">=5.4.0" + "typescript": ">=5.1.0" }, "peerDependenciesMeta": { - "better-sqlite3": { - "optional": true - }, "typescript": { "optional": true } @@ -5551,25 +5185,6 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/proper-lockfile/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, - "license": "ISC" - }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -5672,31 +5287,6 @@ "destr": "^2.0.3" } }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -5734,23 +5324,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/regexp-to-ast": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", - "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/remeda": { - "version": "2.33.4", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", - "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "devOptional": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/remeda" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5784,16 +5357,6 @@ "node": ">=8" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -5862,14 +5425,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "devOptional": true, - "license": "MIT", - "peer": true - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5922,12 +5477,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "devOptional": true - }, "node_modules/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", @@ -5951,7 +5500,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5964,7 +5513,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6046,7 +5595,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -6126,16 +5675,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -6157,13 +5696,6 @@ "node": ">= 0.8" } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, - "license": "MIT" - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6449,14 +5981,11 @@ } }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/tmpl": { "version": "1.0.5", @@ -6645,21 +6174,6 @@ "node": ">=10.12.0" } }, - "node_modules/valibot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", - "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -6682,7 +6196,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6988,17 +6502,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zeptomatch": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", - "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "grammex": "^3.1.11", - "graphmatch": "^1.1.0" - } } } } diff --git a/package.json b/package.json index b4285ae..989093f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,6 @@ }, "devDependencies": { "jest": "^30.1.3", - "prisma": "^7.4.1" + "prisma": "^6.16.2" } } diff --git a/screenshot.png b/screenshot.png index 13d17b2..25ee627 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/src/public/assets/css/styles.css b/src/public/assets/css/styles.css new file mode 100644 index 0000000..8811cad --- /dev/null +++ b/src/public/assets/css/styles.css @@ -0,0 +1,115 @@ +/* used to prevent ui flicker before vuejs loads */ +[v-cloak] { + display: none; +} + +.icon-longfast { + background-color: #009016; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-mediumfast { + background-color: #326be7; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-shortslow { + background-color: #0077e6; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-mqtt-connected { + background-color: #2563eb; /* Change to use same color as disconnected // #16a34a; */ + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-mqtt-disconnected { + background-color: #2563eb; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-offline { + background-color: #e2286c; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-position-history { + background-color: #a855f7; + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-traceroute-start { + background-color: #16a34a; /* green */ + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.icon-traceroute-end { + background-color: #dc2626; /* red */ + border-radius: 25px; + border: 1px solid #2C2D3C; +} + +.waypoint-label { + font-size: 26px; + background-color: transparent; +} + +.link { + color: #2563eb; +} + +.link:hover { + text-decoration: underline; +} + +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltip-text { + visibility: hidden; + width: 80px; + background-color: black; + color: #fff; + text-align: center; + padding: 4px 0; + border-radius: 6px; + position: absolute; + z-index: 10000; + top: 100%; + left: 50%; + margin-top: 8px; + margin-left: -40px; /* Use half of the width (120/2 = 60), to center the tooltip */ +} + +.tooltip .tooltip-text::after { + content: " "; + position: absolute; + bottom: 100%; /* At the top of the tooltip */ + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent transparent black transparent; +} + +.tooltip:hover .tooltip-text { + visibility: visible; +} + +.z-search { + z-index: 1001; +} + +.z-sidebar { + z-index: 1002; +} \ No newline at end of file diff --git a/src/public/assets/js/app.js b/src/public/assets/js/app.js new file mode 100644 index 0000000..86b4542 --- /dev/null +++ b/src/public/assets/js/app.js @@ -0,0 +1,956 @@ +Vue.createApp({ + data() { + return { + + isShowingAnnouncement: this.shouldShowAnnouncement(), + + configNodesMaxAgeInSeconds: window.getConfigNodesMaxAgeInSeconds(), + configNodesOfflineAgeInSeconds: window.getConfigNodesOfflineAgeInSeconds(), + configWaypointsMaxAgeInSeconds: window.getConfigWaypointsMaxAgeInSeconds(), + configConnectionsMaxDistanceInMeters: window.getConfigConnectionsMaxDistanceInMeters(), + configZoomLevelGoToNode: window.getConfigZoomLevelGoToNode(), + configAutoUpdatePositionInUrl: window.getConfigAutoUpdatePositionInUrl(), + configEnableMapAnimations: window.getConfigEnableMapAnimations(), + configTemperatureFormat: window.getConfigTemperatureFormat(), + configConnectionsTimePeriodInSeconds: window.getConfigConnectionsTimePeriodInSeconds(), + configConnectionsColoredLines: window.getConfigConnectionsColoredLines(), + configConnectionsBidirectionalOnly: window.getConfigConnectionsBidirectionalOnly(), + configConnectionsMinSnrDb: window.getConfigConnectionsMinSnrDb(), + configConnectionsBidirectionalMinSnr: window.getConfigConnectionsBidirectionalMinSnr(), + + isShowingHardwareModels: false, + hardwareModelStats: null, + + isShowingInfoModal: this.shouldShowInfoModal(), + isShowingMobileSearch: false, + isShowingSettings: false, + + nodes: [], + searchText: "", + + selectedNode: null, + selectedNodeDeviceMetrics: [], + selectedNodeEnvironmentMetrics: [], + selectedNodePowerMetrics: [], + selectedNodeMqttMetrics: [], + selectedNodeTraceroutes: [], + + deviceMetricsTimeRange: "7d", + environmentMetricsTimeRange: "7d", + powerMetricsTimeRange: "7d", + + isPositionHistoryModalExpanded: true, + positionHistoryDateTimeFrom: null, + positionHistoryDateTimeTo: null, + selectedNodePositionHistory: [], + selectedNodeToShowPositionHistory: null, + selectedNodePositionHistoryMarkers: [], + selectedNodePositionHistoryPolyLines: [], + + selectedTraceRoute: null, + tracerouteEdges: [], + + selectedNodeToShowConnections: null, + + moment: window.moment, + + }; + }, + mounted: function() { + + // load data + this.loadHardwareModelStats(); + + // handle map click callback from outside of vue + window._onMapClick = () => { + this.searchText = ""; + this.isShowingMobileSearch = false; + }; + + // handle node callback from outside of vue + window._onNodeClick = (node) => { + this.selectedNode = node; + this.loadNodeDeviceMetrics(node.node_id); + this.loadNodeEnvironmentMetrics(node.node_id); + this.loadNodePowerMetrics(node.node_id); + this.loadNodeMqttMetrics(node.node_id); + this.loadNodeTraceroutes(node.node_id); + //this.loadNodePositionHistory(node.node_id); + }; + + // handle node callback from outside of vue + window._onShowNodeConnectionsClick = (node) => { + this.selectedNodeToShowConnections = node; + }; + + // handle nodes updated callback from outside of vue + window._onNodesUpdated = (nodes) => { + this.nodes = nodes; + }; + + }, + methods: { + getAnnouncementId: function() { + // change this when making a new announcement + return "1"; + }, + shouldShowAnnouncement: function() { + const lastSeenAnnouncementId = window.localStorage.getItem("last-seen-announcement-id"); + return lastSeenAnnouncementId?.toString() !== this.getAnnouncementId(); + }, + dismissAnnouncement: function() { + window.localStorage.setItem("last-seen-announcement-id", this.getAnnouncementId()); + this.isShowingAnnouncement = false; + }, + shouldShowInfoModal: function() { + return !window.getConfigHasSeenInfoModal() + && !window.isMobile(); + }, + loadHardwareModelStats: function() { + window.axios.get('/api/v1/stats/hardware-models').then((response) => { + this.hardwareModelStats = response.data.hardware_model_stats; + }).catch((error) => { + // do nothing + }); + }, + loadNodeDeviceMetrics: function(nodeId) { + + // calculate unix timestamps in milliseconds for supported time ranges + const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000); + const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000); + const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000); + const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10); + + // determine how long back to load device metrics from + var timeFrom = threeDaysAgoInMilliseconds; + switch(this.deviceMetricsTimeRange){ + case "1d": { + timeFrom = oneDayAgoInMilliseconds; + break; + } + case "3d": { + timeFrom = threeDaysAgoInMilliseconds; + break; + } + case "7d": { + timeFrom = sevenDaysAgoInMilliseconds; + break; + } + case "30d": { + timeFrom = thirtyDaysAgoInMilliseconds; + break; + } + } + + window.axios.get(`/api/v1/nodes/${nodeId}/device-metrics`, { + params: { + time_from: timeFrom, + }, + }).then((response) => { + // reverse response, as it's newest to oldest, but we want oldest to newest + this.selectedNodeDeviceMetrics = response.data.device_metrics.reverse(); + this.renderDeviceMetricCharts(); + }).catch(() => { + this.selectedNodeDeviceMetrics = []; + this.renderDeviceMetricCharts(); + }); + }, + loadNodeEnvironmentMetrics: function(nodeId) { + + // calculate unix timestamps in milliseconds for supported time ranges + const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000); + const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000); + const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000); + const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10); + + // determine how long back to load environment metrics from + var timeFrom = threeDaysAgoInMilliseconds; + switch(this.environmentMetricsTimeRange){ + case "1d": { + timeFrom = oneDayAgoInMilliseconds; + break; + } + case "3d": { + timeFrom = threeDaysAgoInMilliseconds; + break; + } + case "7d": { + timeFrom = sevenDaysAgoInMilliseconds; + break; + } + case "30d": { + timeFrom = thirtyDaysAgoInMilliseconds; + break; + } + } + + window.axios.get(`/api/v1/nodes/${nodeId}/environment-metrics`, { + params: { + time_from: timeFrom, + }, + }).then((response) => { + // reverse response, as it's newest to oldest, but we want oldest to newest + this.selectedNodeEnvironmentMetrics = response.data.environment_metrics.reverse(); + this.renderEnvironmentMetricCharts(); + }).catch(() => { + this.selectedNodeEnvironmentMetrics = []; + this.renderEnvironmentMetricCharts(); + }); + }, + loadNodePowerMetrics: function(nodeId) { + + // calculate unix timestamps in milliseconds for supported time ranges + const oneDayAgoInMilliseconds = new Date().getTime() - (86400 * 1000); + const threeDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000); + const sevenDaysAgoInMilliseconds = new Date().getTime() - (604800 * 1000); + const thirtyDaysAgoInMilliseconds = new Date().getTime() - (259200 * 1000 * 10); + + // determine how long back to load power metrics from + var timeFrom = threeDaysAgoInMilliseconds; + switch(this.powerMetricsTimeRange){ + case "1d": { + timeFrom = oneDayAgoInMilliseconds; + break; + } + case "3d": { + timeFrom = threeDaysAgoInMilliseconds; + break; + } + case "7d": { + timeFrom = sevenDaysAgoInMilliseconds; + break; + } + case "30d": { + timeFrom = thirtyDaysAgoInMilliseconds; + break; + } + } + + window.axios.get(`/api/v1/nodes/${nodeId}/power-metrics`, { + params: { + time_from: timeFrom, + }, + }).then((response) => { + // reverse response, as it's newest to oldest, but we want oldest to newest + this.selectedNodePowerMetrics = response.data.power_metrics.reverse(); + this.renderPowerMetricCharts(); + }).catch(() => { + this.selectedNodePowerMetrics = []; + this.renderPowerMetricCharts(); + }); + }, + loadNodeMqttMetrics: function(nodeId) { + this.selectedNodeMqttMetrics = []; + window.axios.get(`/api/v1/nodes/${nodeId}/mqtt-metrics`).then((response) => { + this.selectedNodeMqttMetrics = response.data.mqtt_metrics; + }).catch(() => { + // do nothing + }); + }, + loadNodeTraceroutes: function(nodeId) { + this.selectedNodeTraceroutes = []; + window.axios.get(`/api/v1/nodes/${nodeId}/traceroutes`, { + params: { + count: 5, + }, + }).then((response) => { + this.selectedNodeTraceroutes = response.data.traceroutes; + }).catch(() => { + // do nothing + }); + }, + loadNodePositionHistory: function(nodeId) { + this.selectedNodePositionHistory = []; + window.axios.get(`/api/v1/nodes/${nodeId}/position-history`, { + params: { + // parse from datetime-local format, and send as unix timestamp in milliseconds + time_from: moment(this.positionHistoryDateTimeFrom, "YYYY-MM-DDTHH:mm").format("x"), + time_to: moment(this.positionHistoryDateTimeTo, "YYYY-MM-DDTHH:mm").format("x"), + }, + }).then((response) => { + this.selectedNodePositionHistory = response.data.position_history; + if(this.selectedNodeToShowPositionHistory != null){ + clearAllPositionHistory(); + onPositionHistoryUpdated(response.data.position_history); + } + + }).catch(() => { + // do nothing + }); + }, + renderDeviceMetricCharts: function() { + try { + this.updateDeviceMetricsChart(); + } catch(e) { + console.log(e); + } + }, + updateDeviceMetricsChart: function() { + + // destroy existing chart + const chartElementId = "deviceMetricsChart"; + const existingChart = window.Chart.getChart(chartElementId); + if(existingChart != null){ + existingChart.destroy(); + } + + // get chart element + const chartElement = window.document.getElementById(chartElementId); + if(!chartElement){ + return; + } + + // create chart data + const labels = []; + const batteryMetrics = []; + const channelUtilizationMetrics = []; + const airUtilTxMetrics = []; + for(const deviceMetric of this.selectedNodeDeviceMetrics){ + labels.push(moment(deviceMetric.created_at)); + batteryMetrics.push(deviceMetric.battery_level); + channelUtilizationMetrics.push(deviceMetric.channel_utilization); + airUtilTxMetrics.push(deviceMetric.air_util_tx); + } + + // create chart + new window.Chart(chartElement, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Battery Level', + borderColor: '#3b82f6', + backgroundColor: '#3b82f6', + pointStyle: false, // no points + fill: false, + data: batteryMetrics, + }, + { + label: 'Channel Util', + borderColor: '#22c55e', + backgroundColor: '#22c55e', + showLine: false, // no lines between points + fill: false, + data: channelUtilizationMetrics, + }, + { + label: 'Air Util TX', + borderColor: '#f97316', + backgroundColor: '#f97316', + showLine: false, // no lines between points + fill: false, + data: airUtilTxMetrics, + + }, + ], + }, + options: { + responsive: true, + borderWidth: 2, + elements: { + point: { + radius: 2, + }, + }, + scales: { + x: { + position: 'top', + type: 'time', + time: { + unit: 'day', + displayFormats: { + day: 'MMM DD', // Jan 01 + }, + }, + }, + y: { + min: 0, + max: 101, // 101 is "Plugged In", need to include for tooltip to work + ticks: { + callback: (label) => `${label}%`, + }, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: "index", + intersect: false, + callbacks: { + label: (item) => { + return `${item.dataset.label}: ${item.formattedValue}%`; + }, + }, + }, + }, + } + }); + + }, + renderEnvironmentMetricCharts: function() { + try { + this.updateEnvironmentMetricsChart(); + } catch(e) { + console.log(e); + } + }, + updateEnvironmentMetricsChart: function() { + + // destroy existing chart + const chartElementId = "environmentMetricsChart"; + const existingChart = window.Chart.getChart(chartElementId); + if(existingChart != null){ + existingChart.destroy(); + } + + // get chart element + const chartElement = window.document.getElementById(chartElementId); + if(!chartElement){ + return; + } + + // create chart data + const labels = []; + const temperatureMetrics = []; + const relativeHumidityMetrics = []; + const barometricPressureMetrics = []; + const iaqMetrics = []; + for(const deviceMetric of this.selectedNodeEnvironmentMetrics){ + labels.push(moment(deviceMetric.created_at)); + temperatureMetrics.push(deviceMetric.temperature); + relativeHumidityMetrics.push(deviceMetric.relative_humidity); + barometricPressureMetrics.push(deviceMetric.barometric_pressure); + iaqMetrics.push(deviceMetric.iaq); + } + + // create chart + new window.Chart(chartElement, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Temperature', + suffix: '°C', + borderColor: '#3b82f6', + backgroundColor: '#3b82f6', + pointStyle: false, // no points + fill: false, + data: temperatureMetrics, + yAxisID: 'y', + }, + { + label: 'Humidity', + suffix: '%', + borderColor: '#22c55e', + backgroundColor: '#22c55e', + pointStyle: false, // no points + fill: false, + data: relativeHumidityMetrics, + yAxisID: 'y', + }, + { + label: 'Pressure', + suffix: 'hPa', + borderColor: '#f97316', + backgroundColor: '#f97316', + pointStyle: false, // no points + fill: false, + data: barometricPressureMetrics, + yAxisID: 'y1', + + }, + { + label: 'IAQ', + suffix: 'IAQ', + borderColor: '#f472b6', + backgroundColor: '#f472b6', + pointStyle: false, // no points + fill: false, + data: iaqMetrics, + yAxisID: 'yIAQ', + + }, + ], + }, + options: { + responsive: true, + borderWidth: 2, + spanGaps: 1000 * 60 * 60 * 24, // only show lines between metrics with a 24 hour or less gap + elements: { + point: { + radius: 2, + }, + }, + scales: { + x: { + position: 'top', + type: 'time', + time: { + unit: 'day', + displayFormats: { + day: 'MMM DD', // Jan 01 + }, + }, + }, + y: { + min: -20, + max: 100, + }, + y1: { + min: 800, + max: 1100, + ticks: { + stepSize: 10, + callback: (label) => `${label} hPa`, + }, + position: 'right', + grid: { + drawOnChartArea: false, // only want the grid lines for one axis to show up + }, + }, + yIAQ: { + type: 'linear', + display: false, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: "index", + intersect: false, + callbacks: { + label: (item) => { + return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`; + }, + }, + }, + }, + } + }); + + }, + renderPowerMetricCharts: function() { + try { + this.updatePowerMetricsChart(); + } catch(e) { + console.log(e); + } + }, + updatePowerMetricsChart: function() { + + // destroy existing chart + const chartElementId = "powerMetricsChart"; + const existingChart = window.Chart.getChart(chartElementId); + if(existingChart != null){ + existingChart.destroy(); + } + + // get chart element + const chartElement = window.document.getElementById(chartElementId); + if(!chartElement){ + return; + } + + // create chart data + const labels = []; + const channel1VoltageReadings = []; + const channel2VoltageReadings = []; + const channel3VoltageReadings = []; + const channel1CurrentReadings = []; + const channel2CurrentReadings = []; + const channel3CurrentReadings = []; + for(const powerMetric of this.selectedNodePowerMetrics){ + labels.push(moment(powerMetric.created_at)); + channel1VoltageReadings.push(powerMetric.ch1_voltage); + channel2VoltageReadings.push(powerMetric.ch2_voltage); + channel3VoltageReadings.push(powerMetric.ch3_voltage); + channel1CurrentReadings.push(powerMetric.ch1_current); + channel2CurrentReadings.push(powerMetric.ch2_current); + channel3CurrentReadings.push(powerMetric.ch3_current); + } + + // create chart + new window.Chart(chartElement, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Ch1 Voltage', + suffix: "V", + borderColor: '#3b82f6', + backgroundColor: '#3b82f6', + pointStyle: false, // no points + fill: false, + data: channel1VoltageReadings, + yAxisID: 'y', + }, + { + label: 'Ch2 Voltage', + suffix: "V", + borderColor: '#22c55e', + backgroundColor: '#22c55e', + pointStyle: false, // no points + fill: false, + data: channel2VoltageReadings, + yAxisID: 'y', + }, + { + label: 'Ch3 Voltage', + suffix: "V", + borderColor: '#f97316', + backgroundColor: '#f97316', + pointStyle: false, // no points + fill: false, + data: channel3VoltageReadings, + yAxisID: 'y', + }, + { + label: 'Ch1 Current', + suffix: "mA", + borderColor: '#93c5fd', + backgroundColor: '#93c5fd', + pointStyle: false, // no points + fill: false, + data: channel1CurrentReadings, + yAxisID: 'y1', + }, + { + label: 'Ch2 Current', + suffix: "mA", + borderColor: '#86efac', + backgroundColor: '#86efac', + pointStyle: false, // no points + fill: false, + data: channel2CurrentReadings, + yAxisID: 'y1', + }, + { + label: 'Ch3 Current', + suffix: "mA", + borderColor: '#fdba74', + backgroundColor: '#fdba74', + pointStyle: false, // no points + fill: false, + data: channel3CurrentReadings, + yAxisID: 'y1', + }, + ], + }, + options: { + responsive: true, + borderWidth: 2, + spanGaps: 1000 * 60 * 60 * 3, // only show lines between metrics with a 3 hour or less gap + elements: { + point: { + radius: 2, + }, + }, + scales: { + x: { + position: 'top', + type: 'time', + time: { + unit: 'day', + displayFormats: { + day: 'MMM DD', // Jan 01 + }, + }, + }, + y: { + min: 0, + suggestedMax: 6, + ticks: { + callback: (label) => `${label}V`, + }, + }, + y1: { + suggestedMin: -50, + suggestedMax: 50, + ticks: { + stepSize: 50, + callback: (label) => `${label}mA`, + }, + position: 'right', + grid: { + drawOnChartArea: false, // only want the grid lines for one axis to show up + }, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + mode: "index", + intersect: false, + callbacks: { + label: (item) => { + return `${item.dataset.label}: ${item.formattedValue}${item.dataset.suffix}`; + }, + }, + }, + }, + } + }); + + }, + showTraceRoute: function(traceroute) { + this.selectedTraceRoute = traceroute; + }, + findNodeById: function(id) { + return window.findNodeById(id); + }, + findNodeMarkerById: function(id) { + return window.findNodeMarkerById(id); + }, + onSearchResultNodeClick: function(node) { + + // clear search + this.searchText = ""; + + // hide search + this.isShowingMobileSearch = false; + + // go to node + if(window.goToNode(node.node_id)){ + return; + } + + // fallback to showing node details since we can't go to the node + window.showNodeDetails(node.node_id); + + }, + dismissInfoModal: function() { + this.isShowingInfoModal = false; + window.setConfigHasSeenInfoModal(true); + }, + getRegionFrequencyRange: function(regionName) { + return window.getRegionFrequencyRange(regionName); + }, + showNodePositionHistory: function(nodeId) { + + // find node + const node = findNodeById(nodeId); + if(!node){ + return; + } + + // update ui + this.selectedNode = null; + this.selectedNodeToShowPositionHistory = node; + this.isPositionHistoryModalExpanded = true; + + // close node info tooltip as position history shows under it + window.closeAllTooltips(); + + // reset default time range when opening position history ui + // YYYY-MM-DDTHH:mm is the format expected by the datetime-local input type + this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm'); + this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm'); + + // load position history + this.loadNodePositionHistory(nodeId); + + }, + onPositionHistoryQuickRangeClick: function(range) { + + // update position history time range + switch(range){ + case "1h": { + this.positionHistoryDateTimeFrom = moment().subtract(1, "hours").format('YYYY-MM-DDTHH:mm'); + this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm'); + break; + } + case "24h": { + this.positionHistoryDateTimeFrom = moment().subtract(24, "hours").format('YYYY-MM-DDTHH:mm'); + this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm'); + break; + } + case "7d": { + this.positionHistoryDateTimeFrom = moment().subtract(7, "days").format('YYYY-MM-DDTHH:mm'); + this.positionHistoryDateTimeTo = moment().format('YYYY-MM-DDTHH:mm'); + break; + } + } + + // reload position history + const node = this.selectedNodeToShowPositionHistory; + if(node){ + this.loadNodePositionHistory(node.node_id); + } + + }, + getShareLinkForNode: function(nodeId) { + return window.location.origin + `/?node_id=${nodeId}`; + }, + copyShareLinkForNode: function(nodeId) { + + // make sure copy to clipboard is supported + if(!navigator.clipboard || !navigator.clipboard.writeText){ + alert("Clipboard not supported. Site must be served via https on iOS."); + return; + } + + // copy share link to clipboard + const url = this.getShareLinkForNode(nodeId); + navigator.clipboard.writeText(url); + + // tell user we copied it + alert("Link copied to clipboard!"); + + }, + dismissShowingNodeConnections: function() { + window._onHideNodeConnectionsClick(); + this.selectedNodeToShowConnections = null; + }, + dismissShowingNodePositionHistory: function() { + this.selectedNodePositionHistory = []; + this.selectedNodeToShowPositionHistory = null; + this.selectedNodePositionHistoryMarkers = []; + this.selectedNodePositionHistoryPolyLines = []; + cleanUpPositionHistory(); + }, + formatUptimeSeconds: function(secondsToFormat) { + secondsToFormat = Number(secondsToFormat); + var days = Math.floor(secondsToFormat / (3600 * 24)); + var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600); + var minutes = Math.floor((secondsToFormat % 3600) / 60); + var seconds = Math.floor(secondsToFormat % 60); + var daysPlural = days === 1 ? 'day' : 'days'; + return `${days} ${daysPlural} ${hours}h ${minutes}m ${seconds}s`; + }, + formatTemperature: function(celsius) { + switch(this.configTemperatureFormat){ + case "celsius": { + return `${Number(celsius).toFixed(0)}°C`; + } + case "fahrenheit": { + const fahrenheit = this.celsiusToFahrenheit(celsius); + return `${fahrenheit.toFixed(0)}°F`; + } + } + }, + convertTemperature: function(celsius) { + switch(this.configTemperatureFormat){ + case "celsius": { + return celsius; + } + case "fahrenheit": { + return this.celsiusToFahrenheit(celsius); + } + } + }, + getTemperatureUnit: function() { + switch(this.configTemperatureFormat){ + case "celsius": return "°C"; + case "fahrenheit": return "°F"; + } + }, + celsiusToFahrenheit: function(celsius) { + return (celsius * 9/5) + 32; + }, + getNodeColour(nodeId) { + // convert node id to a hex colour + return "#" + (nodeId & 0x00FFFFFF).toString(16).padStart(6, '0'); + }, + getNodeTextColour(nodeId) { + + // extract rgb components + const r = (nodeId & 0xFF0000) >> 16; + const g = (nodeId & 0x00FF00) >> 8; + const b = nodeId & 0x0000FF; + + // calculate brightness + const brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255; + + // determine text color based on brightness + return brightness > 0.5 ? "#000000" : "#FFFFFF"; + + }, + }, + computed: { + searchedNodes() { + + // search nodes + const nodes = this.nodes.filter((node) => { + const matchesId = node.node_id?.toLowerCase()?.includes(this.searchText.toLowerCase()); + const matchesHexId = node.node_id_hex?.toLowerCase()?.includes(this.searchText.toLowerCase()); + const matchesLongName = node.long_name?.toLowerCase()?.includes(this.searchText.toLowerCase()); + const matchesShortName = node.short_name?.toLowerCase()?.includes(this.searchText.toLowerCase()); + return matchesId || matchesHexId || matchesLongName || matchesShortName; + }); + + // order alphabetically by long name + nodes.sort((nodeA, nodeB) => { + const nodeALongName = nodeA.long_name || ""; + const nodeBLongName = nodeB.long_name || ""; + return nodeALongName.localeCompare(nodeBLongName); + }); + + // only return the first 500 results to avoid ui lag... + return nodes.slice(0, 500); + + }, + selectedNodeLatestPowerMetric() { + const [ latestPowerMetric ] = this.selectedNodePowerMetrics.slice(-1); + return latestPowerMetric; + }, + }, + watch: { + configNodesMaxAgeInSeconds() { + window.setConfigNodesMaxAgeInSeconds(this.configNodesMaxAgeInSeconds); + }, + configNodesOfflineAgeInSeconds() { + window.setConfigNodesOfflineAgeInSeconds(this.configNodesOfflineAgeInSeconds); + }, + configWaypointsMaxAgeInSeconds() { + window.setConfigWaypointsMaxAgeInSeconds(this.configWaypointsMaxAgeInSeconds); + }, + configConnectionsMaxDistanceInMeters() { + window.setConfigConnectionsMaxDistanceInMeters(this.configConnectionsMaxDistanceInMeters); + }, + configZoomLevelGoToNode() { + window.setConfigZoomLevelGoToNode(this.configZoomLevelGoToNode); + }, + configAutoUpdatePositionInUrl() { + window.setConfigAutoUpdatePositionInUrl(this.configAutoUpdatePositionInUrl); + }, + configEnableMapAnimations() { + window.setConfigEnableMapAnimations(this.configEnableMapAnimations); + }, + configTemperatureFormat() { + window.setConfigTemperatureFormat(this.configTemperatureFormat); + }, + configConnectionsTimePeriodInSeconds() { + window.setConfigConnectionsTimePeriodInSeconds(this.configConnectionsTimePeriodInSeconds); + }, + configConnectionsColoredLines() { + window.setConfigConnectionsColoredLines(this.configConnectionsColoredLines); + }, + configConnectionsBidirectionalOnly() { + window.setConfigConnectionsBidirectionalOnly(this.configConnectionsBidirectionalOnly); + }, + configConnectionsMinSnrDb() { + window.setConfigConnectionsMinSnrDb(this.configConnectionsMinSnrDb); + }, + configConnectionsBidirectionalMinSnr() { + window.setConfigConnectionsBidirectionalMinSnr(this.configConnectionsBidirectionalMinSnr); + }, + deviceMetricsTimeRange() { + this.loadNodeDeviceMetrics(this.selectedNode.node_id); + }, + environmentMetricsTimeRange() { + this.loadNodeEnvironmentMetrics(this.selectedNode.node_id); + }, + powerMetricsTimeRange() { + this.loadNodePowerMetrics(this.selectedNode.node_id); + }, + }, +}).mount('#app'); \ No newline at end of file diff --git a/src/public/assets/js/config.js b/src/public/assets/js/config.js new file mode 100644 index 0000000..5866a4c --- /dev/null +++ b/src/public/assets/js/config.js @@ -0,0 +1,199 @@ +function getConfigHasSeenInfoModal() { + return localStorage.getItem("config_has_seen_info_modal") === "true"; +} + +function setConfigHasSeenInfoModal(value) { + return localStorage.setItem("config_has_seen_info_modal", value); +} + +function getConfigAutoUpdatePositionInUrl() { + // use user preference, or enable by default + const value = localStorage.getItem("config_auto_update_position_in_url"); + return value === "true" || value == null; +} + +function setConfigAutoUpdatePositionInUrl(value) { + return localStorage.setItem("config_auto_update_position_in_url", value); +} + +function getConfigEnableMapAnimations() { + + const value = localStorage.getItem("config_enable_map_animations"); + + // enable animations by default + if(value === null){ + return true; + } + + return value === "true"; + +} + +function setConfigEnableMapAnimations(value) { + return localStorage.setItem("config_enable_map_animations", value); +} + +function getConfigTemperatureFormat() { + return localStorage.getItem("config_temperature_format") || "celsius"; +} + +function setConfigTemperatureFormat(format) { + return localStorage.setItem("config_temperature_format", format); +} + +function getConfigMapSelectedTileLayer() { + return localStorage.getItem("config_map_selected_tile_layer") || "Thunderforest Neighbourhood"; +} + +function setConfigMapSelectedTileLayer(layer) { + return localStorage.setItem("config_map_selected_tile_layer", layer); +} + +function getConfigMapEnabledOverlayLayers() { + + try { + const value = localStorage.getItem("config_map_enabled_overlay_layers"); + if(value){ + return JSON.parse(value); + } + } catch(e) {} + + // overlays enabled by default + return ["Legend", "Position History", "Traceroutes"]; + +} + +function setConfigMapEnabledOverlayLayers(layers) { + return localStorage.setItem("config_map_enabled_overlay_layers", JSON.stringify(layers)); +} + +function getConfigNodesMaxAgeInSeconds() { + const value = localStorage.getItem("config_nodes_max_age_in_seconds"); + return value != null ? parseInt(value) : null; +} + +function setConfigNodesMaxAgeInSeconds(value) { + if(value != null){ + return localStorage.setItem("config_nodes_max_age_in_seconds", value); + } else { + return localStorage.removeItem("config_nodes_max_age_in_seconds"); + } +} + +function getConfigNodesOfflineAgeInSeconds() { + const value = localStorage.getItem("config_nodes_offline_age_in_seconds"); + return value != null ? parseInt(value) : 10800; +} + +function setConfigNodesOfflineAgeInSeconds(value) { + if(value != null){ + return localStorage.setItem("config_nodes_offline_age_in_seconds", value); + } else { + return localStorage.removeItem("config_nodes_offline_age_in_seconds"); + } +} + +function getConfigWaypointsMaxAgeInSeconds() { + const value = localStorage.getItem("config_waypoints_max_age_in_seconds"); + return value != null ? parseInt(value) : null; +} + +function setConfigWaypointsMaxAgeInSeconds(value) { + if(value != null){ + return localStorage.setItem("config_waypoints_max_age_in_seconds", value); + } else { + return localStorage.removeItem("config_waypoints_max_age_in_seconds"); + } +} + +function getConfigConnectionsMaxDistanceInMeters() { + const value = localStorage.getItem("config_connections_max_distance_in_meters"); + // default to 70km (70,000 meters) + return value != null ? parseInt(value) : 70000; +} + +function setConfigConnectionsMaxDistanceInMeters(value) { + return localStorage.setItem("config_connections_max_distance_in_meters", value); +} + +function getConfigZoomLevelGoToNode() { + const value = localStorage.getItem("config_zoom_level_go_to_node"); + const parsedValue = value != null ? parseInt(value) : null; + return parsedValue || 15; +} + +function setConfigZoomLevelGoToNode(value) { + return localStorage.setItem("config_zoom_level_go_to_node", value); +} + +function getConfigConnectionsTimePeriodInSeconds() { + const value = localStorage.getItem("config_connections_time_period_in_seconds"); + // default to 7 days if unset + return value != null ? parseInt(value) : 604800; +} + +function setConfigConnectionsTimePeriodInSeconds(value) { + return localStorage.setItem("config_connections_time_period_in_seconds", value); +} + +function getConfigConnectionsColoredLines() { + const value = localStorage.getItem("config_connections_colored_lines"); + // disable colored lines by default + if(value === null){ + return false; + } + return value === "true"; +} + +function setConfigConnectionsColoredLines(value) { + return localStorage.setItem("config_connections_colored_lines", value); +} + +function getConfigConnectionsBidirectionalOnly() { + const value = localStorage.getItem("config_connections_bidirectional_only"); + // disable bidirectional filter by default + if(value === null){ + return false; + } + return value === "true"; +} + +function setConfigConnectionsBidirectionalOnly(value) { + return localStorage.setItem("config_connections_bidirectional_only", value); +} + +function getConfigConnectionsMinSnrDb() { + const value = localStorage.getItem("config_connections_min_snr_db"); + // default to null (unset) + if(value === null || value === ""){ + return null; + } + const parsed = parseFloat(value); + return isNaN(parsed) ? null : parsed; +} + +function setConfigConnectionsMinSnrDb(value) { + if(value === null || value === "" || value === undefined){ + return localStorage.removeItem("config_connections_min_snr_db"); + } + // Convert to string for localStorage (handles both number and string inputs) + const stringValue = typeof value === "number" ? value.toString() : String(value); + return localStorage.setItem("config_connections_min_snr_db", stringValue); +} + +function getConfigConnectionsBidirectionalMinSnr() { + const value = localStorage.getItem("config_connections_bidirectional_min_snr"); + // disable bidirectional minimum SNR by default + if(value === null){ + return false; + } + return value === "true"; +} + +function setConfigConnectionsBidirectionalMinSnr(value) { + return localStorage.setItem("config_connections_bidirectional_min_snr", value); +} + +function isMobile() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +} \ No newline at end of file diff --git a/src/public/assets/js/map.js b/src/public/assets/js/map.js new file mode 100644 index 0000000..e25c067 --- /dev/null +++ b/src/public/assets/js/map.js @@ -0,0 +1,1692 @@ +// global state +var nodes = []; +var nodeMarkers = {}; +var selectedNodeOutlineCircle = null; +var waypoints = []; + +// set map bounds to be a little more than full size to prevent panning off screen +var bounds = [ + [-100, 70], // top left + [100, 500], // bottom right +]; + +// create map positioned over NRW +if(!isMobile()){ + var map = L.map('map', { + maxBounds: bounds, + }).setView([ + 51.1, + 366.82, + ], 9); +} else { + var map = L.map('map', { + maxBounds: bounds, + }).setView([ + 51.1, + 366.82, + ], 8); +} + +// remove leaflet link +map.attributionControl.setPrefix(''); + +var openThunderforestLandscapeMapTileLayer = L.tileLayer('https://tiles.nixware.dev/landscape/{z}/{x}/{y}.png', { + maxZoom: 22, + attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic', +}); + +var openThunderforestAtlasMapTileLayer = L.tileLayer('https://tiles.nixware.dev/atlas/{z}/{x}/{y}.png', { + maxZoom: 22, + attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic', +}); + +var openThunderforestNeighbourhoodMapTileLayer = L.tileLayer('https://tiles.nixware.dev/neighbourhood/{z}/{x}/{y}.png', { + maxZoom: 22, + attribution: 'Tiles © Gravitystorm Limited | Data from Meshtastic', +}); + +var openStreetMapTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 22, // increase from 18 to 22 + attribution: 'Tiles © OpenStreetMap | Data from Meshtastic', +}); + +var openTopoMapTileLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { + maxZoom: 17, // open topo map doesn't have tiles closer than this + attribution: 'Tiles © OpenStreetMap | Data from Meshtastic', +}); + +var esriWorldImageryTileLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + maxZoom: 21, // esri doesn't have tiles closer than this + attribution: 'Tiles © Esri | Data from Meshtastic' +}); + +var googleSatelliteTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', { + maxZoom: 21, + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], + attribution: 'Tiles © Google | Data from Meshtastic' +}); + +var googleHybridTileLayer = L.tileLayer('https://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', { + maxZoom: 21, + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], + attribution: 'Tiles © Google | Data from Meshtastic' +}); + +var tileLayers = { + "Thunderforest Neighbourhood": openThunderforestNeighbourhoodMapTileLayer, + "Thunderforest Landscape": openThunderforestLandscapeMapTileLayer, + "Thunderforest Atlas": openThunderforestAtlasMapTileLayer, + "OpenStreetMap": openStreetMapTileLayer, + "OpenTopoMap": openTopoMapTileLayer, + "Esri Satellite": esriWorldImageryTileLayer, + "Google Satellite": googleSatelliteTileLayer, + "Google Hybrid": googleHybridTileLayer, +}; + +// use tile layer based on config +const selectedTileLayerName = getConfigMapSelectedTileLayer(); +const selectedTileLayer = tileLayers[selectedTileLayerName] || openThunderforestNeighbourhoodMapTileLayer; +selectedTileLayer.addTo(map); + +// create layer groups +var nodesLayerGroup = new L.LayerGroup(); +var backboneConnectionsLayerGroup = new L.LayerGroup(); +var nodeConnectionsLayerGroup = new L.LayerGroup(); +var nodesClusteredLayerGroup = L.markerClusterGroup({ + showCoverageOnHover: false, + disableClusteringAtZoom: 10, // zoom level where node clustering is disabled +}); +var nodesRouterLayerGroup = L.markerClusterGroup({ + showCoverageOnHover: false, + disableClusteringAtZoom: 10, // zoom level where node clustering is disabled +}); +var nodesBackboneLayerGroup = new L.LayerGroup(); +//var nodesMediumFastLayerGroup = new L.LayerGroup(); +var nodesShortSlowLayerGroup = new L.LayerGroup(); +var nodesLongFastLayerGroup = new L.LayerGroup(); +var waypointsLayerGroup = new L.LayerGroup(); +var nodePositionHistoryLayerGroup = new L.LayerGroup(); +var traceroutesLayerGroup = new L.LayerGroup(); +var connectionsLayerGroup = new L.LayerGroup(); + +// create icons +var iconMqttConnected = L.divIcon({ + className: 'icon-mqtt-connected', + iconSize: [16, 16], // increase from 12px to 16px to make hover easier +}); + +var iconLongFast = L.divIcon({ + className: 'icon-longfast', + iconSize: [16, 16], // increase from 12px to 16px to make hover easier +}); + +/*var iconMediumFast = L.divIcon({ + className: 'icon-mediumfast', + iconSize: [16, 16], // increase from 12px to 16px to make hover easier +});*/ + +var iconShortSlow = L.divIcon({ + className: 'icon-shortslow', + iconSize: [16, 16], +}); + +var iconMqttDisconnected = L.divIcon({ + className: 'icon-mqtt-disconnected', + iconSize: [16, 16], // increase from 12px to 16px to make hover easier +}); + +var iconOffline = L.divIcon({ + className: 'icon-offline', + iconSize: [16, 16], // increase from 12px to 16px to make hover easier +}); + +var iconPositionHistory = L.divIcon({ + className: 'icon-position-history', + iconSize: [16, 16], // increase from 12px to 16px to make hover easier +}); + +var iconTracerouteStart = L.divIcon({ + className: 'icon-traceroute-start', + iconSize: [16, 16], +}); + +var iconTracerouteEnd = L.divIcon({ + className: 'icon-traceroute-end', + iconSize: [16, 16], +}); + +// create legend +var legendLayerGroup = new L.LayerGroup(); +var legend = L.control({position: 'bottomleft'}); +legend.onAdd = function (map) { + var div = L.DomUtil.create('div', 'leaflet-control-layers'); + div.style.backgroundColor = 'white'; + div.style.padding = '12px'; + div.innerHTML = `
Legend
` + + `
ShortSlow
` + //+ `
MediumFast
` + + `
LongFast
` + + `
Offline Too Long
` + + `
Traceroute
`; + return div; +}; + +// handle baselayerchange to update tile layer preference +map.on('baselayerchange', function(event) { + setConfigMapSelectedTileLayer(event.name); +}); + +// handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup) +map.on('overlayadd overlayremove', function(event) { + if(event.name === "Legend"){ + if(event.type === "overlayadd"){ + map.addControl(legend); + } else if(event.type === "overlayremove"){ + map.removeControl(legend); + } + } +}); + +// add layers to control ui +L.control.groupedLayers(tileLayers, { + "Nodes": { + "All": nodesLayerGroup, + "Routers": nodesRouterLayerGroup, + "Backbone": nodesBackboneLayerGroup, + "ShortSlow": nodesShortSlowLayerGroup, + //"MediumFast": nodesMediumFastLayerGroup, + "LongFast": nodesLongFastLayerGroup, + "Clustered": nodesClusteredLayerGroup, + "None": new L.LayerGroup(), + }, + "Overlays": { + "Legend": legendLayerGroup, + "Backbone Connections": backboneConnectionsLayerGroup, + "Connections": connectionsLayerGroup, + "Waypoints": waypointsLayerGroup, + "Position History": nodePositionHistoryLayerGroup, + "Traceroutes": traceroutesLayerGroup, + }, +}, { + // make the "Nodes" group exclusive (use radio inputs instead of checkbox) + exclusiveGroups: ["Nodes"], +}).addTo(map); + +// enable base layers +nodesLayerGroup.addTo(map); + +// enable overlay layers based on config +const enabledOverlayLayers = getConfigMapEnabledOverlayLayers(); +if(enabledOverlayLayers.includes("Legend")){ + legendLayerGroup.addTo(map); +} +if(enabledOverlayLayers.includes("Backbone Connection")){ + backboneConnectionsLayerGroup.addTo(map); +} +if(enabledOverlayLayers.includes("Connections")){ + connectionsLayerGroup.addTo(map); +} +if(enabledOverlayLayers.includes("Waypoints")){ + waypointsLayerGroup.addTo(map); +} +if(enabledOverlayLayers.includes("Position History")){ + nodePositionHistoryLayerGroup.addTo(map); +} +if(enabledOverlayLayers.includes("Traceroutes")){ + traceroutesLayerGroup.addTo(map); +} + +map.on('overlayadd', function(event) { + // update config when map overlay is added + const layerName = event.name; + const enabledOverlayLayers = getConfigMapEnabledOverlayLayers(); + if(!enabledOverlayLayers.includes(layerName)){ + enabledOverlayLayers.push(layerName); + } + setConfigMapEnabledOverlayLayers(enabledOverlayLayers); + + // clear traceroutes layer when traceroutes overlay is added + if (layerName === "Traceroutes") { + traceroutesLayerGroup.clearLayers(); + } +}); + +// update config when map overlay is removed +map.on('overlayremove', function(event) { + const layerName = event.name; + const enabledOverlayLayers = getConfigMapEnabledOverlayLayers().filter(function(enabledOverlayLayer) { + return enabledOverlayLayer !== layerName; + }); + setConfigMapEnabledOverlayLayers(enabledOverlayLayers); +}); + +// handle map clicks +map.on('click', function() { + + // remove outline when map clicked + clearNodeOutline(); + + // send callback to vue + window._onMapClick(); + +}); + +// close all tooltips and popups when clicking map +map.on("click", function(event) { + + // do nothing when clicking inside tooltip + const clickedElement = event.originalEvent.target; + if(elementOrAnyAncestorHasClass(clickedElement, "leaflet-tooltip")){ + return; + } + + closeAllTooltips(); + closeAllPopups(); + +}); + +function isValidLatLng(lat, lng) { + + if(isNaN(lat) || isNaN(lng)){ + return false; + } + + return true; + +} + +function findNodeById(id) { + + // find node by id + var node = nodes.find((node) => node.node_id.toString() === id.toString()); + if(node){ + return node; + } + + return null; + +} + +function findNodeMarkerById(id) { + + // find node marker by id + var nodeMarker = nodeMarkers[id]; + if(nodeMarker){ + return nodeMarker; + } + + return null; + +} + +function goToNode(id, animate, zoom){ + + // find node + var node = findNodeById(id); + if(!node){ + alert("Could not find node: " + id); + return false; + } + + // find node marker by id + var nodeMarker = findNodeMarkerById(id); + if(!nodeMarker){ + return false; + } + + // close all popups and tooltips + closeAllPopups(); + closeAllTooltips(); + + // select node + showNodeOutline(id); + + // fly to node marker + const shouldAnimate = animate != null ? animate : true; + map.flyTo(nodeMarker.getLatLng(), zoom || getConfigZoomLevelGoToNode(), { + animate: getConfigEnableMapAnimations() ? shouldAnimate : false, + }); + + // open tooltip for node + map.openTooltip(getTooltipContentForNode(node), nodeMarker.getLatLng(), { + interactive: true, // allow clicking buttons inside tooltip + permanent: true, // don't auto dismiss when clicking buttons inside tooltip + }); + + // successfully went to node + return true; + +} + +function goToRandomNode() { + if(nodes.length > 0){ + const randomNode = nodes[Math.floor(Math.random() * nodes.length)]; + if(randomNode){ + + // go to node + if(window.goToNode(randomNode.node_id)){ + return; + } + + // fallback to showing node details since we can't go to the node + window.showNodeDetails(randomNode.node_id); + + } + } +} + +function clearAllNodes() { + nodesLayerGroup.clearLayers(); + nodesClusteredLayerGroup.clearLayers(); + nodesRouterLayerGroup.clearLayers(); + nodesBackboneLayerGroup.clearLayers(); + nodesShortSlowLayerGroup.clearLayers(); + //nodesMediumFastLayerGroup.clearLayers(); + nodesLongFastLayerGroup.clearLayers(); +} + +function clearAllBackboneConnections() { + backboneConnectionsLayerGroup.clearLayers(); +} + +function clearAllWaypoints() { + waypointsLayerGroup.clearLayers(); +} + +function clearAllTraceroutes() { + traceroutesLayerGroup.clearLayers(); +} + +function clearAllConnections() { + connectionsLayerGroup.clearLayers(); + backboneConnectionsLayerGroup.clearLayers(); +} + +function closeAllPopups() { + map.eachLayer(function(layer) { + if(layer.options.pane === "popupPane"){ + layer.removeFrom(map); + } + }); +} + +function closeAllTooltips() { + map.eachLayer(function(layer) { + if(layer.options.pane === "tooltipPane"){ + layer.removeFrom(map); + } + }); +} + +function clearAllPositionHistory() { + nodePositionHistoryLayerGroup.clearLayers(); +} + +function clearNodeOutline() { + if(selectedNodeOutlineCircle){ + selectedNodeOutlineCircle.removeFrom(map); + selectedNodeOutlineCircle = null; + } +} + +function showNodeOutline(id) { + + // remove any existing node circle + clearNodeOutline(); + + // find node marker by id + const nodeMarker = nodeMarkers[id]; + if(!nodeMarker){ + return; + } + + // find node by id + const node = findNodeById(id); + if(!node){ + return; + } + + // add position precision circle around node + if(node.position_precision != null && node.position_precision > 0 && node.position_precision < 32){ + selectedNodeOutlineCircle = L.circle(nodeMarker.getLatLng(), { + radius: getPositionPrecisionInMeters(node.position_precision), + }).addTo(map); + } + +} + +function showNodeDetails(id) { + + // find node + const node = findNodeById(id); + if(!node){ + return; + } + + // fire callback to vuejs handler + window._onNodeClick(node); + +} + +function getColourForSnr(snr) { + if(snr >= -4) return "#16a34a"; // good + if(snr > -8) return "#fff200"; // medium-good + if(snr > -12) return "#ff9f1c"; // medium + return "#dc2626"; // bad +} + +function getSignalBarsIndicator(snrDb) { + if(snrDb == null) return ''; + + // Determine number of bars based on SNR + let bars = 0; + if(snrDb >= -4) bars = 4; // good + else if(snrDb > -8) bars = 3; // medium-good + else if(snrDb > -12) bars = 2; // medium + else bars = 1; // bad + + const color = getColourForSnr(snrDb); + + // Create 4 bars with increasing height + let indicator = ''; + + // Bar heights: 4px, 6px, 8px, 10px + const barHeights = [4, 6, 8, 10]; + const barWidth = 2; + + for (let i = 0; i < 4; i++) { + const height = barHeights[i]; + const isActive = i < bars; + const barColor = isActive ? color : '#d1d5db'; // gray for inactive bars + indicator += ``; + } + + indicator += ''; + return indicator; +} + +function cleanUpNodeConnections() { + + // close tooltips and popups + closeAllPopups(); + closeAllTooltips(); + + // setup node connections layer + nodeConnectionsLayerGroup.clearLayers(); + nodeConnectionsLayerGroup.removeFrom(map); + nodeConnectionsLayerGroup.addTo(map); + +} + +function getTerrainProfileImage(node1, node2) { + + // line colour between nodes + const lineColour = "0000FF"; // blue + + // node 1 (left side of image) + const node1MarkerColour = "0000FF"; // blue + const node1Latitude = node1.latitude; + const node1Longitude = node1.longitude; + const node1ElevationMSL = node1.altitude ?? ""; + + // node 2 (right side of image) + const node2MarkerColour = "0000FF"; // blue + const node2Latitude = node2.latitude; + const node2Longitude = node2.longitude; + const node2ElevationMSL = node2.altitude ?? ""; + + // generate terrain profile image url + return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({ + src: "meshtastic.liamcottle.net", + axes: 1, // include grid lines and a scale + metric: 1, // show metric units + curvature: 1, + width: 500, + height: 200, + pt0: `${node1Latitude},${node1Longitude},${lineColour},${node1ElevationMSL},${node1MarkerColour}`, + pt1: `${node2Latitude},${node2Longitude},${lineColour},${node2ElevationMSL},${node2MarkerColour}`, + }).toString(); + +} + +async function showNodeConnections(id) { + + cleanUpNodeConnections(); + + // find node + const node = findNodeById(id); + if(!node){ + return; + } + + // find node marker + const nodeMarker = findNodeMarkerById(node.node_id); + if(!nodeMarker){ + return; + } + + // show overlay for node connections + window._onShowNodeConnectionsClick(node); + + // Fetch connections for this node + const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds(); + const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined; + const connectionsParams = new URLSearchParams(); + connectionsParams.set('node_id', id); + if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom); + + try { + const response = await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`); + const connections = response.data.connections ?? []; + + for (const connection of connections) { + // Convert to numbers for comparison since API returns strings + const nodeA = parseInt(connection.node_a, 10); + const nodeB = parseInt(connection.node_b, 10); + + const otherNodeId = nodeA === id ? nodeB : nodeA; + const otherNode = findNodeById(otherNodeId); + const otherNodeMarker = findNodeMarkerById(otherNodeId); + + if (!otherNode || !otherNodeMarker) continue; + + // Apply bidirectional filter + const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly(); + if(configConnectionsBidirectionalOnly){ + const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null; + const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null; + if(!hasDirectionAB || !hasDirectionBA){ + continue; + } + } + + // Apply minimum SNR filter + const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb(); + if(configConnectionsMinSnrDb != null){ + const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null; + const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null; + + const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr(); + let hasSnrAboveThreshold; + + if(configConnectionsBidirectionalMinSnr){ + // Bidirectional mode: ALL existing directions must meet threshold + const directionsToCheck = []; + if(snrAB != null) directionsToCheck.push(snrAB); + if(snrBA != null) directionsToCheck.push(snrBA); + + if(directionsToCheck.length === 0){ + // No SNR data in either direction, skip + hasSnrAboveThreshold = false; + } else { + // All existing directions must be above threshold + hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb); + } + } else { + // Default mode: EITHER direction has SNR above threshold + hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb); + } + + if(!hasSnrAboveThreshold){ + continue; + } + } + + // Calculate distance + const distanceInMeters = nodeMarker.getLatLng().distanceTo(otherNodeMarker.getLatLng()).toFixed(2); + const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters(); + if(configConnectionsMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){ + continue; + } + + let distance = `${distanceInMeters} meters`; + if (distanceInMeters >= 1000) { + const distanceInKilometers = (distanceInMeters / 1000).toFixed(2); + distance = `${distanceInKilometers} kilometers`; + } + + // Determine line color + const configConnectionsColoredLines = getConfigConnectionsColoredLines(); + const worstSnrDb = connection.worst_avg_snr_db; + const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb'; + + // Create bidirectional line + const line = L.polyline([ + nodeMarker.getLatLng(), + otherNodeMarker.getLatLng(), + ], { + color: lineColor, + opacity: 0.75, + weight: 3, + }).addTo(nodeConnectionsLayerGroup); + + // Generate tooltip using standardized function + const tooltipNodeA = findNodeById(connection.node_a); + const tooltipNodeB = findNodeById(connection.node_b); + const tooltip = generateConnectionTooltip(connection, tooltipNodeA, tooltipNodeB, distance); + + line.bindTooltip(tooltip, { + sticky: true, + opacity: 1, + interactive: true, + }) + .bindPopup(tooltip) + .on('click', function(event) { + event.target.closeTooltip(); + }); + } + } catch (err) { + console.error('Error fetching connections:', err); + } + +} + +function clearMap() { + closeAllPopups(); + closeAllTooltips(); + clearAllNodes(); + clearAllBackboneConnections(); + clearAllWaypoints(); + clearAllTraceroutes(); + clearAllConnections(); + clearNodeOutline(); + cleanUpNodeConnections(); +} + +// returns true if the element or one of its parents has the class classname +function elementOrAnyAncestorHasClass(element, className) { + + // check if element contains class + if(element.classList && element.classList.contains(className)){ + return true; + } + + // check if parent node has the class + if(element.parentNode){ + return elementOrAnyAncestorHasClass(element.parentNode, className); + } + + // couldn't find the class + return false; + +} + +// escape strings for tooltips etc, to prevent html/script injection +// not used in vuejs, as that auto escapes +function escapeString(string) { + return string.replace(//g, ">"); +} + + +function onNodesUpdated(updatedNodes) { + + // clear nodes cache + nodes = []; + + // get config + const now = moment(); + const configNodesMaxAgeInSeconds = getConfigNodesMaxAgeInSeconds(); + const configNodesOfflineAgeInSeconds = getConfigNodesOfflineAgeInSeconds(); + const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters(); + + // add nodes + for(const node of updatedNodes){ + + // skip nodes older than configured node max age + if(configNodesMaxAgeInSeconds){ + const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at)); + if(lastUpdatedAgeInMillis > configNodesMaxAgeInSeconds * 1000){ + continue; + } + } + + // add to cache + nodes.push(node); + + // skip nodes without position + if(!node.latitude || !node.longitude){ + continue; + } + + // fix lat long + node.latitude = node.latitude / 10000000; + node.longitude = node.longitude / 10000000; + + // skip nodes with invalid position + if(!isValidLatLng(node.latitude, node.longitude)){ + continue; + } + + // wrap longitude for shortest path, everything to left of australia should be shown on the right + var longitude = parseFloat(node.longitude); + if(longitude <= 100){ + longitude += 360; + } + + // icon based on channel preset + var icon = iconLongFast; + + if (node.channel_id == "ShortSlow") { + icon = iconShortSlow; + } + + /*if (node.channel_id == "MediumFast") { + icon = iconMediumFast; + }*/ + + // use offline icon for nodes older than configured node offline age + if(configNodesOfflineAgeInSeconds){ + const lastUpdatedAgeInMillis = now.diff(moment(node.updated_at)); + if(lastUpdatedAgeInMillis > configNodesOfflineAgeInSeconds * 1000){ + icon = iconOffline; + } + } + + // determine zIndexOffset: MediumFast (1000), LongFast (-1000), Offline (-2000) + var zIndexOffset = 1000; + if(icon == iconOffline){ + zIndexOffset = -2000; + } else if(node.channel_id == 'LongFast'){ + zIndexOffset = -1000; + } + + // To not have overlapping nodes. + var latJitter = 0; + var lonJitter = 0; + // If position pression > 45m apply random jitter within a small circle to avoid diagonal-only displacement + if (node.position_precision < 19) { + const maxMeters = 40; + const r = maxMeters * Math.sqrt(Math.random()); + const theta = 2 * Math.PI * Math.random(); + const dy = r * Math.sin(theta); + const dx = r * Math.cos(theta); + const metersPerDegLat = 111320; + const metersPerDegLon = 111320 * Math.cos(node.latitude * Math.PI / 180); + latJitter = dy / metersPerDegLat; + lonJitter = metersPerDegLon ? (dx / metersPerDegLon) : 0; + } + + // create node marker + const marker = L.marker([node.latitude + latJitter, longitude + lonJitter], { + icon: icon, + tagName: node.node_id, + // zIndex: offline (-2000) < has channel_id (-1000) < others (1000) + zIndexOffset: zIndexOffset, + }).on('click', function(event) { + // close tooltip on click to prevent tooltip and popup showing at same time + event.target.closeTooltip(); + }); + + // add marker to node layer groups + marker.addTo(nodesLayerGroup); + nodesClusteredLayerGroup.addLayer(marker); + + // add markers for routers and repeaters to routers layer group + if(node.role_name === "ROUTER" + || node.role_name === "ROUTER_CLIENT" + || node.role_name === "ROUTER_LATE" + || node.role_name === "REPEATER"){ + nodesRouterLayerGroup.addLayer(marker); + } + + // add markers for backbone to layer group + if(node.is_backbone) { + nodesBackboneLayerGroup.addLayer(marker); + } + + if(node.channel_id == "ShortSlow") { + nodesShortSlowLayerGroup.addLayer(marker); + } + + // add markers for MediumFast channel to layer group + /*if(node.channel_id == "MediumFast") { + nodesMediumFastLayerGroup.addLayer(marker); + }*/ + + // add markers for LongFast channel to layer group + if(node.channel_id == "LongFast") { + nodesLongFastLayerGroup.addLayer(marker); + } + + // show tooltip on desktop only + if(!isMobile()){ + marker.bindTooltip(getTooltipContentForNode(node), { + interactive: true, + }); + } + + // show node info tooltip when clicking node marker + marker.on("click", function(event) { + + // close all other popups and tooltips + closeAllTooltips(); + closeAllPopups(); + + // find node + const node = findNodeById(event.target.options.tagName); + if(!node){ + return; + } + + // show position precision outline + showNodeOutline(node.node_id); + + // open tooltip for node + map.openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), { + interactive: true, // allow clicking buttons inside tooltip + permanent: true, // don't auto dismiss when clicking buttons inside tooltip + }); + + }); + + // add to cache + nodeMarkers[node.node_id] = marker; + + } + + window._onNodesUpdated(nodes); + +} + +function onWaypointsUpdated(updatedWaypoints) { + + // clear nodes cache + waypoints = []; + + // get config + const now = moment(); + const configWaypointsMaxAgeInSeconds = getConfigWaypointsMaxAgeInSeconds(); + + // add nodes + for(const waypoint of updatedWaypoints){ + + // skip waypoints older than configured waypoint max age + if(configWaypointsMaxAgeInSeconds){ + const lastUpdatedAgeInMillis = now.diff(moment(waypoint.updated_at)); + if(lastUpdatedAgeInMillis > configWaypointsMaxAgeInSeconds * 1000){ + continue; + } + } + + // skip expired waypoints + if(waypoint.expire < Date.now() / 1000){ + continue; + } + + // skip waypoints without position + if(!waypoint.latitude || !waypoint.longitude){ + continue; + } + + // fix lat long + waypoint.latitude = waypoint.latitude / 10000000; + waypoint.longitude = waypoint.longitude / 10000000; + + // skip waypoints with invalid position + if(!isValidLatLng(waypoint.latitude, waypoint.longitude)){ + continue; + } + + // wrap longitude for shortest path, everything to left of australia should be shown on the right + var longitude = parseFloat(waypoint.longitude); + if(longitude <= 100){ + longitude += 360; + } + + // determine emoji to show as marker icon + const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon; + const emojiText = String.fromCodePoint(emoji) + + var tooltip = getTooltipContentForWaypoint(waypoint); + + // create waypoint marker + const marker = L.marker([waypoint.latitude, longitude], { + icon: L.divIcon({ + className: 'waypoint-label', + iconSize: [26, 26], // increase from 12px to 26px + html: emojiText, + }), + }).bindPopup(tooltip).on('click', function(event) { + // close tooltip on click to prevent tooltip and popup showing at same time + event.target.closeTooltip(); + }); + + // show tooltip on desktop only + if(!isMobile()){ + marker.bindTooltip(tooltip, { + interactive: true, + }); + } + + // add marker to waypoints layer groups + marker.addTo(waypointsLayerGroup); + + // add to cache + waypoints.push(waypoint); + + } + +} + + +function generateConnectionTooltip(connection, nodeA, nodeB, distance) { + let tooltip = `Connection`; + tooltip += `
[${escapeString(nodeA.short_name)}] ${escapeString(nodeA.long_name)} <-> [${escapeString(nodeB.short_name)}] ${escapeString(nodeB.long_name)}`; + tooltip += `
Distance: ${distance}`; + tooltip += `
`; + + // Direction A -> B + if (connection.direction_ab.avg_snr_db != null) { + tooltip += `
${escapeString(nodeA.short_name)} -> ${escapeString(nodeB.short_name)}:`; + tooltip += `
SNR: ${connection.direction_ab.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ab.avg_snr_db)} (Average of ${connection.direction_ab.total_count} edges)`; + if (connection.direction_ab.last_5_edges.length > 0) { + tooltip += `
Last 5 edges:`; + for (const edge of connection.direction_ab.last_5_edges) { + const timeAgo = moment(new Date(edge.created_at)).fromNow(); + const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?"); + tooltip += `
   ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`; + } + } else { + tooltip += `
No recent edges`; + } + } + + // Direction B -> A + if (connection.direction_ba.avg_snr_db != null) { + tooltip += `

${escapeString(nodeB.short_name)} -> ${escapeString(nodeA.short_name)}:`; + tooltip += `
SNR: ${connection.direction_ba.avg_snr_db.toFixed(1)}dB ${getSignalBarsIndicator(connection.direction_ba.avg_snr_db)} (Average of ${connection.direction_ba.total_count} edges)`; + if (connection.direction_ba.last_5_edges.length > 0) { + tooltip += `
Last 5 edges:`; + for (const edge of connection.direction_ba.last_5_edges) { + const timeAgo = moment(new Date(edge.created_at)).fromNow(); + const sourceIcon = edge.source === "TRACEROUTE_APP" ? "⇵" : (edge.source === "NEIGHBORINFO_APP" ? "✳" : "?"); + tooltip += `
   ${edge.snr_db.toFixed(1)}dB ${getSignalBarsIndicator(edge.snr_db)} (${timeAgo} by:${sourceIcon})`; + } + } else { + tooltip += `
No recent edges`; + } + } + + // Add terrain profile image + const terrainImageUrl = getTerrainProfileImage(nodeA, nodeB); + tooltip += `

Terrain images from HeyWhatsThat.com`; + tooltip += `
`; + + return tooltip; +} + +function onConnectionsUpdated(connections) { + // Clear existing connections + clearAllConnections(); + + for (const connection of connections) { + // Find both node markers + const nodeAMarker = findNodeMarkerById(connection.node_a); + const nodeBMarker = findNodeMarkerById(connection.node_b); + + // Skip if either node marker doesn't exist + if (!nodeAMarker || !nodeBMarker) { + continue; + } + + // Find node objects for names and terrain profile + const nodeA = findNodeById(connection.node_a); + const nodeB = findNodeById(connection.node_b); + + if (!nodeA || !nodeB) { + continue; + } + + // Apply bidirectional filter + const configConnectionsBidirectionalOnly = getConfigConnectionsBidirectionalOnly(); + if(configConnectionsBidirectionalOnly){ + const hasDirectionAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null; + const hasDirectionBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null; + if(!hasDirectionAB || !hasDirectionBA){ + continue; + } + } + + // Apply minimum SNR filter + const configConnectionsMinSnrDb = getConfigConnectionsMinSnrDb(); + if(configConnectionsMinSnrDb != null){ + const snrAB = connection.direction_ab && connection.direction_ab.avg_snr_db != null ? connection.direction_ab.avg_snr_db : null; + const snrBA = connection.direction_ba && connection.direction_ba.avg_snr_db != null ? connection.direction_ba.avg_snr_db : null; + + const configConnectionsBidirectionalMinSnr = getConfigConnectionsBidirectionalMinSnr(); + let hasSnrAboveThreshold; + + if(configConnectionsBidirectionalMinSnr){ + // Bidirectional mode: ALL existing directions must meet threshold + const directionsToCheck = []; + if(snrAB != null) directionsToCheck.push(snrAB); + if(snrBA != null) directionsToCheck.push(snrBA); + + if(directionsToCheck.length === 0){ + // No SNR data in either direction, skip + hasSnrAboveThreshold = false; + } else { + // All existing directions must be above threshold + hasSnrAboveThreshold = directionsToCheck.every(snr => snr > configConnectionsMinSnrDb); + } + } else { + // Default mode: EITHER direction has SNR above threshold + hasSnrAboveThreshold = (snrAB != null && snrAB > configConnectionsMinSnrDb) || (snrBA != null && snrBA > configConnectionsMinSnrDb); + } + + if(!hasSnrAboveThreshold){ + continue; + } + } + + // Calculate distance between nodes + const distanceInMeters = nodeAMarker.getLatLng().distanceTo(nodeBMarker.getLatLng()).toFixed(2); + + // Apply distance filter + const configConnectionsMaxDistanceInMeters = getConfigConnectionsMaxDistanceInMeters(); + if(configConnectionsMaxDistanceInMeters != null && parseFloat(distanceInMeters) > configConnectionsMaxDistanceInMeters){ + continue; + } + + let distance = `${distanceInMeters} meters`; + if (distanceInMeters >= 1000) { + const distanceInKilometers = (distanceInMeters / 1000).toFixed(2); + distance = `${distanceInKilometers} kilometers`; + } + + // Determine line color based on worst average SNR (if colored lines enabled) + const configConnectionsColoredLines = getConfigConnectionsColoredLines(); + const worstSnrDb = connection.worst_avg_snr_db; + const lineColor = configConnectionsColoredLines && worstSnrDb != null ? getColourForSnr(worstSnrDb) : '#2563eb'; + + // Create polyline (bidirectional, no arrows) + const line = L.polyline([ + nodeAMarker.getLatLng(), + nodeBMarker.getLatLng(), + ], { + color: lineColor, + opacity: 0.75, + weight: 3, + }).addTo(connectionsLayerGroup); + + // Generate tooltip + const tooltip = generateConnectionTooltip(connection, nodeA, nodeB, distance); + + // Bind tooltip and popup + line.bindTooltip(tooltip, { + sticky: true, + opacity: 1, + interactive: true, + }) + .bindPopup(tooltip) + .on('click', function(event) { + // close tooltip on click to prevent tooltip and popup showing at same time + event.target.closeTooltip(); + }); + + // If both nodes are backbone nodes, also add to backbone layer group + if (nodeA.is_backbone && nodeB.is_backbone) { + const backboneLine = L.polyline([ + nodeAMarker.getLatLng(), + nodeBMarker.getLatLng(), + ], { + color: lineColor, + opacity: 0.75, + weight: 3, + }).addTo(backboneConnectionsLayerGroup); + + backboneLine.bindTooltip(tooltip, { + sticky: true, + opacity: 1, + interactive: true, + }) + .bindPopup(tooltip) + .on('click', function(event) { + event.target.closeTooltip(); + }); + } + } +} + +function onPositionHistoryUpdated(updatedPositionHistories) { + + let positionHistoryLinesCords = []; + + // add nodes + for(const positionHistory of updatedPositionHistories) { + + // skip position history without position + if(!positionHistory.latitude || !positionHistory.longitude){ + continue; + } + + // find node this position is for + const node = findNodeById(positionHistory.node_id); + if(!node){ + continue; + } + + // fix lat long + positionHistory.latitude = positionHistory.latitude / 10000000; + positionHistory.longitude = positionHistory.longitude / 10000000; + + // skip position history with invalid position + if(!isValidLatLng(positionHistory.latitude, positionHistory.longitude)){ + continue; + } + + // wrap longitude for shortest path, everything to left of australia should be shown on the right + var longitude = parseFloat(positionHistory.longitude); + if(longitude <= 100){ + longitude += 360; + } + + positionHistoryLinesCords.push([positionHistory.latitude, longitude]); + + let tooltip = ""; + if(positionHistory.type === "position"){ + tooltip += `Position`; + } else if(positionHistory.type === "map_report"){ + tooltip += `Map Report`; + } + tooltip += `
[${escapeString(node.short_name)}] ${escapeString(node.long_name)}`; + tooltip += `
${positionHistory.latitude}, ${positionHistory.longitude}`; + tooltip += `
Heard on: ${moment(new Date(positionHistory.created_at)).format("YYYY-MM-DD HH:mm")}`; + + // add gateway info if available + if(positionHistory.gateway_id){ + const gatewayNode = findNodeById(positionHistory.gateway_id); + const gatewayNodeInfo = gatewayNode ? `[${gatewayNode.short_name}] ${gatewayNode.long_name}` : "???"; + tooltip += `
Heard by: ${gatewayNodeInfo}`; + } + + // create position history marker + const marker = L.marker([positionHistory.latitude, longitude],{ + icon: iconPositionHistory, + }).bindTooltip(tooltip).bindPopup(tooltip).on('click', function(event) { + // close tooltip on click to prevent tooltip and popup showing at same time + event.target.closeTooltip(); + }); + + // add marker to position history layer group + marker.addTo(nodePositionHistoryLayerGroup); + + } + + // show lines between position history markers + L.polyline(positionHistoryLinesCords, { + color: "#a855f7", + opacity: 1, + }).addTo(nodePositionHistoryLayerGroup); + +} + +function cleanUpPositionHistory() { + + // close tooltips and popups + closeAllPopups(); + closeAllTooltips(); + + // setup node position history layer + nodePositionHistoryLayerGroup.clearLayers(); + nodePositionHistoryLayerGroup.removeFrom(map); + nodePositionHistoryLayerGroup.addTo(map); + +} + +function setLoading(loading){ + var reloadButton = document.getElementById("reload-button"); + if(loading){ + reloadButton.classList.add("animate-spin"); + } else { + reloadButton.classList.remove("animate-spin"); + } +} + +async function reload(goToNodeId, zoom) { + + // show loading + setLoading(true); + + // clear previous data + clearMap(); + + // fetch nodes + await window.axios.get('/api/v1/nodes').then(async (response) => { + + // update nodes + onNodesUpdated(response.data.nodes); + + // hide loading + setLoading(false); + + // go to node id if provided + if(goToNodeId){ + + // go to node + if(window.goToNode(goToNodeId, false, zoom)){ + return; + } + + // fallback to showing node details since we can't go to the node + window.showNodeDetails(goToNodeId); + + } + + }); + + // fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips) + await window.axios.get('/api/v1/waypoints').then(async (response) => { + onWaypointsUpdated(response.data.waypoints); + }); + + // fetch connections (edges) + const connectionsTimePeriodSec = getConfigConnectionsTimePeriodInSeconds(); + const connectionsTimeFrom = connectionsTimePeriodSec ? (Date.now() - connectionsTimePeriodSec * 1000) : undefined; + const connectionsParams = new URLSearchParams(); + if (connectionsTimeFrom) connectionsParams.set('time_from', connectionsTimeFrom); + await window.axios.get(`/api/v1/connections?${connectionsParams.toString()}`).then(async (response) => { + onConnectionsUpdated(response.data.connections ?? []); + }).catch(() => { + onConnectionsUpdated([]); + }); + +} + +function getRegionFrequencyRange(regionName) { + + // determine lora frequency range based on region_name + // https://github.com/meshtastic/firmware/blob/a4c22321fca6fc8da7bab157c3812055603512ba/src/mesh/RadioInterface.cpp#L21 + const regionNameToLoraFrequencyRange = { + "US": "902-928 MHz", + "EU_433": "433-434 MHz", + "EU_868": "869.4-869.65 MHz", + "CN": "470-510 MHz", + "JP": "920.8-927.8 MHz", + "ANZ": "915-928 MHz", + "RU": "868.7-869.2 MHz", + "KR": "920-923 MHz", + "TW": "920-925 MHz", + "IN": "865-867 MHz", + "NZ_865": "864-868 MHz", + "TH": "920-925 MHz", + "UA_433": "433-434.7 MHz", + "UA_868": "868-868.6 MHz", + "MY_433": "433-435 MHz", + "MY_919": "919-924 MHz", + "SG_923": "917-925 MHz", + "LORA_24": "2.4-2.4835 GHz", + "UNSET": "902-928 MHz", + } + + return regionNameToLoraFrequencyRange[regionName] ?? null; + +} + +function getPositionPrecisionInMeters(positionPrecision) { + switch(positionPrecision){ + case 2: return 5976446; + case 3: return 2988223; + case 4: return 1494111; + case 5: return 747055; + case 6: return 373527; + case 7: return 186763; + case 8: return 93381; + case 9: return 46690; + case 10: return 23345; + case 11: return 11672; // Android LOW_PRECISION + case 12: return 5836; + case 13: return 2918; + case 14: return 1459; + case 15: return 729; + case 16: return 364; // Android MED_PRECISION + case 17: return 182; + case 18: return 91; + case 19: return 45; + case 20: return 22; + case 21: return 11; + case 22: return 5; + case 23: return 2; + case 24: return 1; + case 32: return 0; // Android HIGH_PRECISION + } + return null; +} + +function formatPositionPrecision(positionPrecision) { + + // get position precision in meters + const positionPrecisionInMeters = getPositionPrecisionInMeters(positionPrecision); + if(positionPrecisionInMeters == null){ + return "?"; + } + + // format kilometers + if(positionPrecisionInMeters > 1000){ + const positionPrecisionInKilometers = Math.ceil(positionPrecisionInMeters / 1000); + return `±${positionPrecisionInKilometers}km`; + } + + // format meters + return `±${positionPrecisionInMeters}m`; + +} + +function getTooltipContentForNode(node) { + + var loraFrequencyRange = getRegionFrequencyRange(node.region_name); + + var tooltip = `` + + `${escapeString(node.long_name)}` + + `
Short Name: ${escapeString(node.short_name)}` + + (node.num_online_local_nodes != null ? `
Local Nodes Online: ${node.num_online_local_nodes}` : '') + + (node.position_precision != null && node.position_precision !== 32 ? `
Position Precision: ${formatPositionPrecision(node.position_precision)}` : '') + + `

Role: ${node.role_name}` + + `
Hardware: ${node.hardware_model_name}` + + (node.firmware_version != null ? `
Firmware: ${node.firmware_version}` : '') + + `
OK to MQTT: ${node.ok_to_mqtt}`; + + if(node.battery_level){ + if(node.battery_level > 100){ + tooltip += `
Battery: ${node.battery_level > 100 ? 'Plugged In' : node.battery_level}`; + } else { + tooltip += `
Battery: ${node.battery_level}%`; + } + } + + if(node.voltage){ + tooltip += `
Voltage: ${Number(node.voltage).toFixed(2)}V`; + } + + if(node.channel_utilization){ + tooltip += `
Ch Util: ${Number(node.channel_utilization).toFixed(2)}%`; + } + + if(node.air_util_tx){ + tooltip += `
Air Util: ${Number(node.air_util_tx).toFixed(2)}%`; + } + + // ignore alt above 42949000 due to https://github.com/meshtastic/firmware/issues/3109 + if(node.altitude && node.altitude < 42949000){ + tooltip += `
Altitude: ${node.altitude}m`; + } + + // bottom info + tooltip += `

ID: ${node.node_id}`; + tooltip += `
Hex ID: ${node.node_id_hex}`; + tooltip += `
Updated: ${moment(new Date(node.updated_at)).fromNow()}`; + tooltip += (node.mqtt_connection_state_updated_at ? `
MQTT Updated: ${moment(new Date(node.mqtt_connection_state_updated_at)).fromNow()}` : ''); + tooltip += (node.neighbours_updated_at ? `
Neighbours Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : ''); + tooltip += (node.position_updated_at ? `
Position Updated: ${moment(new Date(node.position_updated_at)).fromNow()}` : ''); + + // show details button + tooltip += `

`; + tooltip += `
`; + tooltip += ``; + + return tooltip; + +} + +function getTooltipContentForWaypoint(waypoint) { + + // get from node name + var fromNode = findNodeById(waypoint.from); + + var tooltip = `${escapeString(waypoint.name)}` + + (waypoint.description ? `
${escapeString(waypoint.description)}` : '') + + `

Expires: ${moment(new Date(waypoint.expire * 1000)).fromNow()}` + + `
Lat/Lng: ${waypoint.latitude}, ${waypoint.longitude}` + + `

From ID: ${waypoint.from}` + + `
From Hex ID: !${Number(waypoint.from).toString(16)}`; + + // show node name this waypoint is from, if possible + if(fromNode != null){ + tooltip += `
From Node: ${escapeString(fromNode.long_name) || 'Unnamed Node'}`; + } else { + tooltip += `
From Node: ???`; + } + + // bottom info + tooltip += `

ID: ${waypoint.waypoint_id}`; + tooltip += `
Updated: ${moment(new Date(waypoint.updated_at)).fromNow()}`; + + return tooltip; + +} + +window._onHideNodeConnectionsClick = function() { + cleanUpNodeConnections(); +}; + +// parse url params +var queryParams = new URLSearchParams(location.search); +var queryNodeId = queryParams.get('node_id'); +var queryLat = queryParams.get('lat'); +var queryLng = queryParams.get('lng'); +var queryZoom = queryParams.get('zoom'); + +// go to lat/lng if provided +if(queryLat && queryLng){ + const zoomLevel = queryZoom || getConfigZoomLevelGoToNode(); + map.flyTo([queryLat, queryLng], zoomLevel, { + animate: false, + }); +} + +// auto update url when lat/lng/zoom changes +map.on("moveend zoomend", function() { + + // check if user enabled auto updating position in url + const autoUpdatePositionInUrl = getConfigAutoUpdatePositionInUrl(); + if(!autoUpdatePositionInUrl){ + return; + } + + // get map info + const latLng = map.getCenter(); + const zoom = map.getZoom(); + + // construct new url + const url = new URL(window.location.href); + url.searchParams.set("lat", latLng.lat); + url.searchParams.set("lng", latLng.lng); + url.searchParams.set("zoom", zoom); + + // update current url + if(window.history.replaceState){ + window.history.replaceState(null, null, url.toString()); + } + +}); + +// reload and go to provided node id +reload(queryNodeId, queryZoom); + +// WebSocket connection for real-time messages +var ws = null; +var tracerouteCooldown = {}; // Track last traceroute time per from node (for 20s cooldown) +var activeTracerouteKeys = new Set(); // Track active traceroute visualizations to prevent duplicates +var tracerouteLines = {}; // Track lines for each traceroute route key for cleanup + +function connectWebSocket() { + // Determine WebSocket URL - use same hostname as current page, port 8081 + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + // Heuristic: if running on localhost, use port 8081; otherwise use /ws path via Nginx + const isLocalhost = location.hostname === 'localhost' || location.hostname === '127.0.0.1'; + const wsUrl = isLocalhost + ? `${wsProtocol}//${location.hostname}:8081` + : `${wsProtocol}//${location.host}/ws`; + + console.log('Connecting to WebSocket:', wsUrl); + ws = new WebSocket(wsUrl); + + ws.onopen = function() { + console.log('WebSocket connected'); + }; + + ws.onmessage = function(event) { + try { + const message = JSON.parse(event.data); + handleWebSocketMessage(message); + } catch (e) { + console.error('Error parsing WebSocket message:', e); + } + }; + + ws.onerror = function(error) { + console.error('WebSocket error:', error); + }; + + ws.onclose = function() { + console.log('WebSocket disconnected, reconnecting in 5 seconds...'); + setTimeout(connectWebSocket, 5000); + }; +} + +function handleWebSocketMessage(message) { + if (message.type === 'traceroute') { + handleTraceroute(message.data); + } +} + +function handleTraceroute(data) { + // Only visualize traceroutes where want_response is false (the reply coming back) + if (data.want_response) { + return; + } + + // When want_response is false, from and to are swapped from the original request + // The path goes from 'to' (original sender) through route to 'from' (original destination) + const originalSender = data.to; // This was the original sender + const originalDestination = data.from; // This was the original destination + const route = data.route || []; + const snrTowards = data.snr_towards || []; + + // Deduplicate: ignore traceroutes from the same original sender for 20 seconds + const now = Date.now(); + if (tracerouteCooldown[originalSender] && (now - tracerouteCooldown[originalSender]) < 20000) { + return; // Still in cooldown period + } + + // Create unique key for this traceroute path to prevent duplicate visualizations + // Use original sender (to), original destination (from), and route to create unique key + // (ignoring gateway_id since multiple gateways can receive same route) + const routeKey = `${originalSender}-${originalDestination}-${route.join('-')}`; + if (activeTracerouteKeys.has(routeKey)) { + return; // Already visualizing this route + } + + // Mark as active and set cooldown + activeTracerouteKeys.add(routeKey); + tracerouteCooldown[originalSender] = now; + + // Build the complete path: to (original sender) -> route[0] -> route[1] -> ... -> from (original destination) + const path = [originalSender]; // Start from original sender + if (route.length > 0) { + path.push(...route); + } + path.push(originalDestination); // End at original destination + + // Visualize the traceroute with animated hops + visualizeTraceroute(path, snrTowards, routeKey); +} + +function visualizeTraceroute(path, snrTowards, routeKey) { + // Verify all nodes in path exist on map + const pathMarkers = []; + for (const nodeId of path) { + const marker = findNodeMarkerById(nodeId); + if (!marker) { + // Node not on map, skip this traceroute + activeTracerouteKeys.delete(routeKey); + return; + } + pathMarkers.push(marker); + } + + // Store lines and overlays for this route key for cleanup + const routeElements = { + lines: [], + startOverlay: null, + endOverlay: null, + }; + tracerouteLines[routeKey] = routeElements; + + // Color starting node (first in path) green and destination node (last in path) red + const startMarker = pathMarkers[0]; + const endMarker = pathMarkers[pathMarkers.length - 1]; + + const startOverlay = L.marker(startMarker.getLatLng(), { + icon: iconTracerouteStart, + zIndexOffset: 10000, // Ensure it's on top + }).addTo(traceroutesLayerGroup); + + const endOverlay = L.marker(endMarker.getLatLng(), { + icon: iconTracerouteEnd, + zIndexOffset: 10000, // Ensure it's on top + }).addTo(traceroutesLayerGroup); + + // Store overlays for cleanup + routeElements.startOverlay = startOverlay; + routeElements.endOverlay = endOverlay; + + // Animate each hop sequentially + let hopIndex = 0; + const animateNextHop = () => { + if (hopIndex >= pathMarkers.length - 1) { + // All hops animated, cleanup after delay + setTimeout(() => { + if (tracerouteLines[routeKey]) { + const routeElements = tracerouteLines[routeKey]; + // Remove all lines + if (routeElements.lines) { + routeElements.lines.forEach(line => { + line.remove(); + }); + } + // Remove node overlays + if (routeElements.startOverlay) { + routeElements.startOverlay.remove(); + } + if (routeElements.endOverlay) { + routeElements.endOverlay.remove(); + } + delete tracerouteLines[routeKey]; + } + activeTracerouteKeys.delete(routeKey); + }, 2500); + return; + } + + const fromMarker = pathMarkers[hopIndex]; + const toMarker = pathMarkers[hopIndex + 1]; + const snr = hopIndex < snrTowards.length ? snrTowards[hopIndex] : null; + + // Use orange color for all traceroute lines + const lineColor = '#f97316'; // orange + + // Create animated polyline for this hop with orange dotted style + const line = L.polyline([fromMarker.getLatLng(), toMarker.getLatLng()], { + color: lineColor, + weight: 4, + opacity: 0, // Start invisible + // dashArray: '10, 5', // Dotted line style + zIndexOffset: 10000, + }).addTo(traceroutesLayerGroup); + + // Fade in animation + line.setStyle({ opacity: 1.0 }); + tracerouteLines[routeKey].lines.push(line); + + // Animate next hop after 700ms delay + hopIndex++; + setTimeout(animateNextHop, 700); + }; + + // Start animation + animateNextHop(); +} + +// Connect WebSocket when page loads +connectWebSocket(); \ No newline at end of file diff --git a/src/public/images/devices/HELTEC_MESH_POCKET.png b/src/public/images/devices/HELTEC_MESH_POCKET.png new file mode 100644 index 0000000..ae7a797 Binary files /dev/null and b/src/public/images/devices/HELTEC_MESH_POCKET.png differ diff --git a/src/public/images/devices/HELTEC_MESH_SOLAR.png b/src/public/images/devices/HELTEC_MESH_SOLAR.png new file mode 100644 index 0000000..a6575ef Binary files /dev/null and b/src/public/images/devices/HELTEC_MESH_SOLAR.png differ diff --git a/src/public/images/devices/HELTEC_V4.png b/src/public/images/devices/HELTEC_V4.png new file mode 100644 index 0000000..6dd4c49 Binary files /dev/null and b/src/public/images/devices/HELTEC_V4.png differ diff --git a/src/public/images/devices/PORTDUINO.png b/src/public/images/devices/PORTDUINO.png new file mode 100644 index 0000000..0ccea0d Binary files /dev/null and b/src/public/images/devices/PORTDUINO.png differ diff --git a/src/public/images/devices/SEEED_SOLAR_NODE.png b/src/public/images/devices/SEEED_SOLAR_NODE.png new file mode 100644 index 0000000..7f894bc Binary files /dev/null and b/src/public/images/devices/SEEED_SOLAR_NODE.png differ diff --git a/src/public/images/devices/SEEED_WIO_TRACKER_L1.png b/src/public/images/devices/SEEED_WIO_TRACKER_L1.png new file mode 100644 index 0000000..e432076 Binary files /dev/null and b/src/public/images/devices/SEEED_WIO_TRACKER_L1.png differ diff --git a/src/public/images/devices/THINKNODE_M1.png b/src/public/images/devices/THINKNODE_M1.png new file mode 100644 index 0000000..7995026 Binary files /dev/null and b/src/public/images/devices/THINKNODE_M1.png differ diff --git a/src/public/images/devices/T_ETH_ELITE.png b/src/public/images/devices/T_ETH_ELITE.png new file mode 100644 index 0000000..3a25fbc Binary files /dev/null and b/src/public/images/devices/T_ETH_ELITE.png differ diff --git a/src/public/images/devices/XIAO_NRF52_KIT.png b/src/public/images/devices/XIAO_NRF52_KIT.png new file mode 100644 index 0000000..f3cc2eb Binary files /dev/null and b/src/public/images/devices/XIAO_NRF52_KIT.png differ diff --git a/src/public/index.html b/src/public/index.html index d2f1f0d..ff76890 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -3,15 +3,15 @@ - STHLM-MESH MAP - + DL4AX Meshtastic Map + - - + + @@ -48,118 +48,8 @@ - + + @@ -168,32 +58,8 @@
- -
- - -
-
Viktigt!
-
- I Stockholm används LoRa preset Medium Range - Fast. För mer info klicka här. -
-
- - - - -
- -
+
@@ -207,13 +73,8 @@
- - - -
-
STHLM-MESH
+
+
@@ -251,16 +112,6 @@ - -
- - - -
-
- - - -
-
-
-
- - - - - -
- - -
- -

Meshtastic Map

-

Created by Liam Cottle

-

Forked by Roslund

-
- - -
-
Beskrivning
-
-
-
Detta är en karta som enbart fokuserar på Stockholm.
-
Den är baserad på Liam Cottle's open source projekt Meshtastic Map, men har flertalet ändringar och nya funktioner som gör att vi bättre kan analysera Meshen i Stockholm.
-
-
-
-
Frågor och svar
-
-
-
Hur får jag min nod att synas på kartan?
-
Din nod behöver anting ha en GPS, ha en fast position inställd, eller att din telefon delar sin position.
-
Utöver detta måste platsdelning vara påslåget under kanalinställningarna för MediumFast kanalen (vanligtvis kanal 0).
-
-
-
Min nod är på fel plats på kartan
-
Detta är troligtvis för att din nod inte delar exakt position. Som standard är positionsprecisionen inställd på ± 3 km, vilket betyder att noden kan befinna sig inom en cirkel med radien 3 kilometer.
-
Du kan ändra positions precisionen i kanalinställningarna. För mer info om positions precisionen, klicka här.
-
-
-
Hur kan jag ansluta min nod till MQTT servern?
-
Då vi enbart vill analysera Meshen i stockholm är MQTT servern inte öppen för alla. Endast ett fåtal noder är uppkopplade till MQTT för att kunna analysera trafiken som faktiskt går över LoRa.
-
De noder som är kopplade mot MQTT servern bör:
-
    -
  • Vara på en unik geografisk plats, då vi vill se hur trafiken fördelas
  • -
  • Ha en stabil fast koppling till internet (via Ethernet eller WiFi)
  • -
  • Ha hög tillgänglighet
  • -
  • Ha direktkontakt med flertalet andra noder
  • -
-
Tror du att din nod kan bidra, kontakta @Roslund på Discord.
-
-
-
- -
-
Legal
-
-
This project is not affiliated with or endorsed by the Meshtastic project.
-
The Meshtastic logo is the trademark of Meshtastic LLC.
-
Map tiles provided by OpenStreetMap
-
-
- - - - -
- -
-
-
-
- - -
- - - - - - - - - - + + +