diff --git a/.DS_Store b/.DS_Store
index b790d6a..63386c8 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/client/package-lock.json b/client/package-lock.json
index 11e6563..e99a7a7 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -11,6 +11,7 @@
"@chakra-ui/react": "^3.22.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
+ "@svar-ui/react-gantt": "^2.7.3",
"axios": "^1.18.1",
"framer-motion": "^12.23.0",
"leaflet": "^1.9.4",
@@ -3106,6 +3107,385 @@
"node": ">=6"
}
},
+ "node_modules/@svar-ui/comments-locales": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/comments-locales/-/comments-locales-2.6.1.tgz",
+ "integrity": "sha512-jouLSwt0rPp2818bHftsb37aU8ERsyBney8XVUHhnWrsIaGJy/0TSOCw6YqN3cae6SQBd6KBO+P1+hBcDEsmUg==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/core-locales": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/core-locales/-/core-locales-2.6.1.tgz",
+ "integrity": "sha512-18xu7qn8Bxwi2EHguVV3Xya5kS+A+6Gnuo2wl55dofp9y948iZ5z7HIRh1+Mj1BgzbdVt6+B04JOCDMLJXyjtg==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/editor-locales": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/editor-locales/-/editor-locales-2.6.1.tgz",
+ "integrity": "sha512-HFEB43W+AEUjoBh3ZsrlLFFTVh8WYPVpZeOiCfptS5hS0qC8pEnqaXtreM6TM1g3W5VHHOR+sXsWkIxAoeKVvg==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/filter-locales": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/filter-locales/-/filter-locales-2.6.1.tgz",
+ "integrity": "sha512-m963r4TnZxiZ6XzfJYvSmiYcmgSgKhkzpOVcUP25AviWle1oPulyv4Qe28Rak3JaAOFAHQAf0EucoaSMJzvK6A==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/filter-store": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/filter-store/-/filter-store-2.6.1.tgz",
+ "integrity": "sha512-2UHeXuNYN56MokBhB48EJ7KvKDVAJbRP2nIbE6t7lbsnWhRsBGxPc4jjUQn/9K+MTGkxrzPrOfeoH8L4p3Hy5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-state": "1.9.6"
+ }
+ },
+ "node_modules/@svar-ui/filter-store/node_modules/@svar-ui/lib-state": {
+ "version": "1.9.6",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
+ "integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/gantt-data-provider": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@svar-ui/gantt-data-provider/-/gantt-data-provider-2.7.2.tgz",
+ "integrity": "sha512-4j+fWq0y7+qfBcu0R5YS13M1u9fKJiH9YQt/81fRRFcRu12Rvpi/HDXeOVpE/APvsM8NoHJdd5kKK6f59qmE+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-data-provider": "1.7.1"
+ }
+ },
+ "node_modules/@svar-ui/gantt-locales": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@svar-ui/gantt-locales/-/gantt-locales-2.7.2.tgz",
+ "integrity": "sha512-yEihUiPx9dgr0FLfAoNpgS6QcVjYLqdW2TbyilZ0UaA3YLORly9lbmCI0PPnAALa73ws0qUy2AYaL1nTq0T4/w==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/gantt-store": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@svar-ui/gantt-store/-/gantt-store-2.7.2.tgz",
+ "integrity": "sha512-krax1GzUWBOTcMVTaaqYlMuJ5lNBkzA6VIhNCnHq+0kFnHg52Hx+D0MQnMkSOUyFA2w0SoiYvUYuAGiQx4eA9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-state": "1.9.7",
+ "date-fns": "3.6.0"
+ }
+ },
+ "node_modules/@svar-ui/grid-data-provider": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/@svar-ui/grid-data-provider/-/grid-data-provider-2.7.3.tgz",
+ "integrity": "sha512-MFobgoczW6wBPjKq8fTORlJEEn2f6jHKWH1h3pC6qmcKr8GRA+TaRaFPVcI2iD5pOc+h/JZxuKE6L+x99S2Uwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-data-provider": "1.7.2"
+ }
+ },
+ "node_modules/@svar-ui/grid-data-provider/node_modules/@svar-ui/lib-data-provider": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-data-provider/-/lib-data-provider-1.7.2.tgz",
+ "integrity": "sha512-Od93Iiy0vBwVzgJm25WdizH3d4Xlli/HAA2kqoUj1pzeZ2SWBeFzvE59iihQz8NuIlfAtQJ6MMe168458T+2lQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-state": "1.9.6"
+ }
+ },
+ "node_modules/@svar-ui/grid-data-provider/node_modules/@svar-ui/lib-state": {
+ "version": "1.9.6",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
+ "integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/grid-locales": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/@svar-ui/grid-locales/-/grid-locales-2.7.3.tgz",
+ "integrity": "sha512-bgB2TfSKjY7mYeTzJjCyOoPco7boz+SQ0xFFsnRp849QN5sJoJEtOl/xnzbR58bLdxq57ne5Z708ozxXDT36lQ==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/grid-store": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/@svar-ui/grid-store/-/grid-store-2.7.3.tgz",
+ "integrity": "sha512-YVmLnFLq7P3ACAos0EiMYHLQbnmjaukmUaMoraTG8vhFsI5BwVaeSFdeC9MQIjbIMlxn/uMA0eEXmZuSxHk2hA==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-dom": "0.12.1",
+ "@svar-ui/lib-state": "1.9.6"
+ }
+ },
+ "node_modules/@svar-ui/grid-store/node_modules/@svar-ui/lib-dom": {
+ "version": "0.12.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-dom/-/lib-dom-0.12.1.tgz",
+ "integrity": "sha512-srvDleQqR6stBBDijCgzDHMFTTffI6sv3XCpNPKmurrnE83L1MDNqezqg6j3+0dUVPd4AAEGOez2DvK6byblTQ==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/grid-store/node_modules/@svar-ui/lib-state": {
+ "version": "1.9.6",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
+ "integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/lib-data-provider": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-data-provider/-/lib-data-provider-1.7.1.tgz",
+ "integrity": "sha512-5Bi9ywngcMTKpGtan0s7q8ivd84Bp0ZnRP+5bLZg6oiRB7YMyN5D5X+apc3CWDAnG4zZn3en4YwS2qc/aHIIuQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-state": "1.9.6"
+ }
+ },
+ "node_modules/@svar-ui/lib-data-provider/node_modules/@svar-ui/lib-state": {
+ "version": "1.9.6",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
+ "integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/lib-dom": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-dom/-/lib-dom-0.14.0.tgz",
+ "integrity": "sha512-9uao5ebcQmuW0RoMu1LCesPsZ7i6G3xIvYAQzX/aeg662Yy/ETsNVy1pXoSa2q5v6ccAMjVxFrFCYReK7fzaJQ==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/lib-react": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-react/-/lib-react-1.3.0.tgz",
+ "integrity": "sha512-jVcREYAMAXOhZB/lbMmLteGKIZoUW3rzmoXCMcFLGcHIGUH7AjFrKOAG/SJvgV0grcG9XyuRTG3jp++6NIGtuw==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-dom": "^0.12.1"
+ }
+ },
+ "node_modules/@svar-ui/lib-react/node_modules/@svar-ui/lib-dom": {
+ "version": "0.12.2",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-dom/-/lib-dom-0.12.2.tgz",
+ "integrity": "sha512-tV9sJ7Xpedxf1dttTc8I190eqlP0UsassUw48uW3JuEQR0G3IRfmH53/X+mYEooc7bcpZDkzYukWEqJeEq9UlQ==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/lib-state": {
+ "version": "1.9.7",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.7.tgz",
+ "integrity": "sha512-nxRdk+54m1RJjGT2yDH7NjxAM5KECxA39kdTZezEIj1ixykyefssB9BrgxKy2jIseYxJMtd6lPZvQ3SZXuvn2g==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/react-comments": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-comments/-/react-comments-2.6.1.tgz",
+ "integrity": "sha512-bdO8DHIZ/qpQNXeZzNuU2cbUsiyen+bc2/sS9h7PLzCSeeGmKwAyHeTyO06WvsVxUgQSBAfp2Eom+xFNDaQcXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/comments-locales": "2.6.1",
+ "@svar-ui/core-locales": "2.6.1",
+ "@svar-ui/lib-data-provider": "1.7.3",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/lib-state": "1.9.7",
+ "@svar-ui/react-core": "2.6.1",
+ "@svar-ui/react-menu": "2.6.1"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/react-comments/node_modules/@svar-ui/lib-data-provider": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-data-provider/-/lib-data-provider-1.7.3.tgz",
+ "integrity": "sha512-+UAM1VybQco5N52iv6rVcDwOJ5G1qE+V+nvaoBHx41H05w4TZTFJL+UnRy99NDLrVbLoCWhoH3L8vyNmZVjTvw==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-state": "1.9.6"
+ }
+ },
+ "node_modules/@svar-ui/react-comments/node_modules/@svar-ui/lib-data-provider/node_modules/@svar-ui/lib-state": {
+ "version": "1.9.6",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
+ "integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/react-core": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-core/-/react-core-2.6.1.tgz",
+ "integrity": "sha512-1b0R+n4r/XLM2FC4mAxhyRYCI79HdrDbfe0nsOr/uJ0Ty+wxEfWglamCf6yJSM2tvmpGJQ388flDIXPbzep4Fg==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/core-locales": "2.6.1",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/lib-react": "1.2.2"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/react-core/node_modules/@svar-ui/lib-react": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-react/-/lib-react-1.2.2.tgz",
+ "integrity": "sha512-bB+Vou2NM0JhMuFW03MsjjoV5Y3xcFfuuK48+7uzUC9Ui1oSWpCK+txOZ+dnUFJISSVjSsXuF34AzMw/2CA63w==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-dom": "^0.11.1"
+ }
+ },
+ "node_modules/@svar-ui/react-core/node_modules/@svar-ui/lib-react/node_modules/@svar-ui/lib-dom": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-dom/-/lib-dom-0.11.1.tgz",
+ "integrity": "sha512-4wgerhDVttvrQ9t6OjHur34Of+VVr2VygA16HHGcWHytgdnOuGgfZb5PeBEhCJw/pO3RKfJvBHbaMIzL4IR//g==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/react-editor": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-editor/-/react-editor-2.6.1.tgz",
+ "integrity": "sha512-r/POft5rvYx43DGHHuC3yKJKani0i+jqKbWFH+j5IWUhWjnkEqdpcToehGzoXob7gyl7VuzGMQQFu8DzUcyNrw==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/editor-locales": "2.6.1",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/lib-state": "1.9.7",
+ "@svar-ui/react-comments": "2.6.1",
+ "@svar-ui/react-core": "2.6.1",
+ "@svar-ui/react-tasklist": "2.6.1",
+ "@svar-ui/react-toolbar": "2.6.1"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/react-filter": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-filter/-/react-filter-2.6.1.tgz",
+ "integrity": "sha512-GBwReaJUa7Qb7EZTJroxjzm7NIGsAEFuDoTrmJqwkcmwFB+WBukQa/2Z0mf5Ya4t4/ldt1sfOzNWlbFhMo8FTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/filter-locales": "2.6.1",
+ "@svar-ui/filter-store": "2.6.1",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/lib-react": "1.3.0",
+ "@svar-ui/lib-state": "1.9.7",
+ "@svar-ui/react-core": "2.6.1",
+ "@svar-ui/react-menu": "2.6.1"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/react-gantt": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-gantt/-/react-gantt-2.7.3.tgz",
+ "integrity": "sha512-1kLEXrfJ3fkXp5NCvMHIpsjdEphy0EC5ce2nphs8GOFlsiSp6nRau+GY0Go6ivGDPdcchPkZ8a0Q1iKk3HtyRA==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/core-locales": "2.6.1",
+ "@svar-ui/gantt-data-provider": "2.7.2",
+ "@svar-ui/gantt-locales": "2.7.2",
+ "@svar-ui/gantt-store": "2.7.2",
+ "@svar-ui/grid-store": "2.7.3",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/lib-react": "1.3.0",
+ "@svar-ui/lib-state": "1.9.7",
+ "@svar-ui/react-comments": "2.6.1",
+ "@svar-ui/react-core": "2.6.1",
+ "@svar-ui/react-editor": "2.6.1",
+ "@svar-ui/react-filter": "2.6.1",
+ "@svar-ui/react-grid": "2.7.4",
+ "@svar-ui/react-menu": "2.6.1",
+ "@svar-ui/react-tasklist": "2.6.1",
+ "@svar-ui/react-toolbar": "2.6.1",
+ "date-fns": "3.6.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/react-grid": {
+ "version": "2.7.4",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-grid/-/react-grid-2.7.4.tgz",
+ "integrity": "sha512-m3AbGuQNeEnniDcyBesEOg+IirwW5NasJQCfJd7jIFhUyfQ79w+crSnLbinEVS59/OKGrqkmxnLDcDwEKbu4rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/grid-data-provider": "2.7.3",
+ "@svar-ui/grid-locales": "2.7.3",
+ "@svar-ui/grid-store": "2.7.3",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/lib-react": "1.3.0",
+ "@svar-ui/lib-state": "1.9.7",
+ "@svar-ui/react-core": "2.6.1",
+ "@svar-ui/react-editor": "2.6.1",
+ "@svar-ui/react-filter": "2.6.1",
+ "@svar-ui/react-menu": "2.6.1"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/react-menu": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-menu/-/react-menu-2.6.1.tgz",
+ "integrity": "sha512-/wu6vaLVPN2AJp8r+axIkjaSpoZOAgE46CRsdM2d0yc+hbJdeMo997LXZTGQzBziwLZttW1DDwOvL+uLIeTOug==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/core-locales": "2.6.1",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/react-core": "2.6.1"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/react-tasklist": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-tasklist/-/react-tasklist-2.6.1.tgz",
+ "integrity": "sha512-U06UHYwL1J6XXMLsHrTvs/wWUXEm3ZC+Ca1hC5Ns0vvnscdE6cSRPqC1Xl2JGKu/cem0sHimEVi7SW4Q2NWczw==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-data-provider": "1.7.3",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/lib-state": "1.9.7",
+ "@svar-ui/react-core": "2.6.1",
+ "@svar-ui/tasklist-locales": "2.6.1"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/react-tasklist/node_modules/@svar-ui/lib-data-provider": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-data-provider/-/lib-data-provider-1.7.3.tgz",
+ "integrity": "sha512-+UAM1VybQco5N52iv6rVcDwOJ5G1qE+V+nvaoBHx41H05w4TZTFJL+UnRy99NDLrVbLoCWhoH3L8vyNmZVjTvw==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/lib-state": "1.9.6"
+ }
+ },
+ "node_modules/@svar-ui/react-tasklist/node_modules/@svar-ui/lib-data-provider/node_modules/@svar-ui/lib-state": {
+ "version": "1.9.6",
+ "resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
+ "integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
+ "license": "MIT"
+ },
+ "node_modules/@svar-ui/react-toolbar": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/react-toolbar/-/react-toolbar-2.6.1.tgz",
+ "integrity": "sha512-jSxcTW8WNgpSgkxicGyu76cKjVuwwxOvdDXJqQpLnxIIdN/RNeWuY8ak4sD0IqhCOp7AicgfPg2t+K4YUp+LMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@svar-ui/core-locales": "2.6.1",
+ "@svar-ui/lib-dom": "0.14.0",
+ "@svar-ui/react-core": "2.6.1"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/@svar-ui/tasklist-locales": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@svar-ui/tasklist-locales/-/tasklist-locales-2.6.1.tgz",
+ "integrity": "sha512-ewGvdmALpEJR3CpzlNX8aDL2JPmeyF1EweRBoI0jQmp7bIpYDIlpTWStmGwDEwQ5SgvUTJylFyIj6baPf2RBlw==",
+ "license": "MIT"
+ },
"node_modules/@swc/core": {
"version": "1.12.7",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.7.tgz",
@@ -5926,6 +6306,16 @@
"integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==",
"license": "BSD-3-Clause"
},
+ "node_modules/date-fns": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
+ "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
diff --git a/client/package.json b/client/package.json
index 7c658b1..7ff304e 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,6 +15,7 @@
"@chakra-ui/react": "^3.22.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
+ "@svar-ui/react-gantt": "^2.7.3",
"axios": "^1.18.1",
"framer-motion": "^12.23.0",
"leaflet": "^1.9.4",
diff --git a/client/src/App.jsx b/client/src/App.jsx
index c8a52e0..ca16750 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -18,6 +18,8 @@ import Volcano from "@/pages/volcano/Volcano.jsx";
import GlobalMap from "./pages/maps/GlobalMap.jsx";
import Admin from "./pages/admin/Admin.jsx";
import MyDashboard from "./pages/home/tabs/MyDashboard.jsx";
+import Settings from "./pages/auth/Settings.jsx";
+import WebAdmin from "./pages/auth/WebAdmin.jsx";
export default function App() {
// 1. Pull the initialization function from Zustand
diff --git a/client/src/components/Widgets/AlertChanges.jsx b/client/src/components/Widgets/AlertChanges.jsx
new file mode 100644
index 0000000..9f6dbd2
--- /dev/null
+++ b/client/src/components/Widgets/AlertChanges.jsx
@@ -0,0 +1,219 @@
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import VdapApi from "../../hooks/useVdapApi.js";
+import useBuildUrl from "../../hooks/useBuildUrl.js";
+import { Footprints, Plane, Sailboat, TrendingUp, TrendingDown } from 'lucide-react';
+import {Box, Collapsible, Flex, Spinner, Text, Timeline, Center} from "@chakra-ui/react";
+import { useMemo, useCallback } from "react";
+
+
+export default function AlertChanges({ settings, widgetID}) {
+ // Find this specific widgets object in the state list of active & displayed widgets
+ const thisWidget = useDashboardStore(useCallback((state) =>
+ state.widgets.find((widget) => widget.id === widgetID), [widgetID]
+ ));
+
+ // build url, default is 7 days past
+ const url = useBuildUrl('/api/v2/alert_changes', settings?.startDate, settings?.endDate);
+
+ // call api hook to retrieve data
+ const {data: apiData, isLoading, error} = VdapApi(url);
+
+ const data = useMemo(() => {
+ if (apiData.length === 0) return [];
+
+ // .reduce() is a built-in array method used to transform an entire array into a single value (like an object)
+ // here it returns a single massive object w/ volcano names as keys
+ const grouped = apiData.reduce((accumulator, item) => {
+ if (!accumulator[item.volcano]) accumulator[item.volcano] = [];
+ accumulator[item.volcano].push(item);
+ return accumulator;
+ }, {});
+
+ // converts the 'grouped' object back into an array of [key, value] pairs
+ const group_arr = Object.entries(grouped);
+
+ // sort data newest to oldest within each volcano
+ const sorted = group_arr.map((item) =>
+ [
+ item[0], // volcano name
+ [...(item[1])].sort((a, b) =>
+ new Date(b.alert_start_date_local) - new Date(a.alert_start_date_local))
+ ]
+ );
+
+ // volcano with most recent alert level change appears at top of list
+ const sorted_entries = [...sorted].sort((a, b) => new Date(b[1][0].alert_start_date_local) - new Date(a[1][0].alert_start_date_local));
+
+ if (thisWidget?.size === "small") {
+ return (sorted_entries.map((volcano) => {
+ const domain = volcano[1].reduce((acc, curr) => {
+ if (!acc[curr.alert_scheme_domain]) acc[curr.alert_scheme_domain] = [];
+ acc[curr.alert_scheme_domain].push(curr);
+ return acc;
+ }, {});
+
+ const domain_arr = Object.entries(domain);
+ return [volcano[0], domain_arr];
+ }));
+ }
+
+ return sorted_entries;
+ }, [apiData, thisWidget]);
+
+ // list of alert colors used for coloring text below
+ const alertColors = {
+ Red: "red.400",
+ Orange: "orange.400",
+ Yellow: "yellow.400",
+ Green: "green.400",
+ };
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (data.length === 0) return (
+
+ No flight level data available for the selected dates.
+
+ );
+
+ return (
+
+ {data.map((volcano, index) => {
+ if (thisWidget?.size === "large") return (
+
+ {/* Using context to dynamically change the border radii of the triggers */}
+
+ {(collapsible) => (
+ <>
+
+
+
+ {volcano[0]} {/* volcano name */}
+ {volcano[1][0]?.alert_start_date_local}
+
+
+ {volcano[1][0]?.alert_level_name}
+
+
+
+
+
+ {volcano[1]?.map((object, index) => (
+
+
+ {/**/}
+
+ {object?.alert_scheme_domain === "terrestrial" && }
+ {object?.alert_scheme_domain === "aviation" && }
+ {object?.alert_scheme_domain === "maritime" && }
+
+
+ {object?.change_type === "increase" ? : }
+
+
+
+ {/*{object?.volcano}*/}
+
+
+ {object?.alert_start_date_local}
+
+ {object?.alert_level_name}
+
+
+ Alert Scale:
+ {(Array.from({ length: Number(object?.alert_scheme_highest) - Number(object?.alert_scheme_lowest) + 1}, (v, i) => Number(object?.alert_scheme_lowest) + i))
+ .map((num) => (
+
+ {num}
+
+ ))
+ }
+
+
+
+
+
+ ))}
+
+
+ >
+ )}
+
+
+ )
+ else if (thisWidget?.size === "small") return (
+ volcano[1]?.map((domain, index) => (
+
+
+
+
+ {domain[0] === "aviation" && }
+ {domain[0] === "terrestrial" && }
+ {domain[0] === "maritime" && }
+
+ {volcano[0] || ""} {/* volcano name */}
+
+ {domain[1]?.[0]?.alert_start_date_local || ""}
+
+
+
+
+ {domain[1]?.[0]?.change_type === "increase" ? : }
+
+
+ {domain[1]?.[0]?.alert_level_name}
+
+
+
+ ))
+ );
+ })}
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/CommCoorReport.jsx b/client/src/components/Widgets/CommCoorReport.jsx
new file mode 100644
index 0000000..f156d2f
--- /dev/null
+++ b/client/src/components/Widgets/CommCoorReport.jsx
@@ -0,0 +1,140 @@
+import {Flex, Button, Spinner, Text} from "@chakra-ui/react";
+import useBuildUrl from "../../hooks/useBuildUrl.js";
+import VdapApi from '../../hooks/useVdapApi.js';
+import { useRef, useEffect } from 'react';
+import {useDashboardStore} from "../../store/useDashboardStore.jsx";
+
+export default function CommCoorReport({ settings, widgetID }) {
+ const reportRef = useRef(null);
+
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+
+ // build url, defaults to 7 days past
+ const url = useBuildUrl('/api/v2/comm_coor_report', settings?.startDate, settings?.endDate);
+ console.log(url);
+
+ // VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
+ const {data: apiData, isLoading, error} = VdapApi(url);
+ console.log(apiData);
+
+ const handleDownloadReport = () => {
+ if (apiData.length === 0) return;
+ const element = document.createElement('a');
+ element.style.display = 'none';
+ // Appends HTML right inside the element, after its last child
+ element.insertAdjacentHTML('afterbegin', apiData?.report_html);
+
+ // 1. Create a blob object with text content and text/plain mime type
+ const blob = new Blob([element.textContent || ""], { type: 'text/plain' });
+
+ // 2. Generate a temporary URL pointing to that blob
+ const fileUrl = URL.createObjectURL(blob);
+
+ // 3. Create a hidden anchor element
+ const linkElement = document.createElement('a');
+ linkElement.href = fileUrl;
+ linkElement.download = `comm_coor_report_${apiData?.start_date}_${apiData?.end_date}`; // Sets the target file name
+ linkElement.style.display = 'none';
+
+ // 4. Append to body, trigger click, and remove it immediately
+ document.body.appendChild(linkElement);
+ linkElement.click();
+ document.body.removeChild(linkElement);
+ document.body.removeChild(element);
+
+ // 5. Free up memory by revoking the blob URL
+ URL.revokeObjectURL(fileUrl);
+ }
+
+ const handleCopyReport = () => {
+ if (apiData.length === 0) return;
+ const element = document.createElement('a');
+ element.style.display = 'none';
+ // Appends HTML right inside the element, after its last child
+ element.insertAdjacentHTML('afterbegin', apiData?.report_html);
+
+ navigator.clipboard.writeText(element.textContent || "")
+ .then(() => {
+ alert('Copied to clipboard!');
+ })
+ .catch((err => {
+ console.error('Failed to copy: ', err);
+ }));
+
+ document.body.removeChild(element);
+ }
+
+ useEffect(() => {
+ if (reportRef.current) {
+ reportRef.current.innerHTML = apiData?.report_html;
+ const p = reportRef.current.querySelectorAll('p');
+ const b = reportRef.current.querySelectorAll('b');
+ p[0].remove();
+ b[0].remove();
+ }
+ }, [apiData]);
+
+ // copy data to clipboard in formatted way when user clicks on copy btn in widget settings
+ useEffect(() => {
+ if (settings?.copyToClipboard === true && reportRef.current) {
+ navigator.clipboard.writeText(reportRef.current.innerText)
+ .then(() => {
+ alert('Copied to clipboard!');
+ })
+ .catch((err => {
+ console.error('Failed to copy: ', err);
+ }));
+ updateSettings(widgetID, {copyToClipboard: false});
+ }
+ }, [settings, widgetID, updateSettings]);
+
+ // download report
+ useEffect(() => {
+ if (settings?.downloadReport === true && reportRef.current) {
+ // 1. Create a blob object with text content and text/plain mime type
+ const blob = new Blob([reportRef.current.innerText || ""], { type: 'text/plain' });
+
+ // 2. Generate a temporary URL pointing to that blob
+ const fileUrl = URL.createObjectURL(blob);
+
+ // 3. Create a hidden anchor element
+ const linkElement = document.createElement('a');
+ linkElement.href = fileUrl;
+ linkElement.download = `comm_coor_report_${apiData?.start_date}_${apiData?.end_date}`; // Sets the target file name
+ linkElement.style.display = 'none';
+
+ // 4. Append to body, trigger click, and remove it immediately
+ document.body.appendChild(linkElement);
+ linkElement.click();
+ document.body.removeChild(linkElement);
+
+ // 5. Free up memory by revoking the blob URL
+ URL.revokeObjectURL(fileUrl);
+ updateSettings(widgetID, {downloadReport: false});
+ }
+ }, [settings, widgetID, updateSettings, apiData]);
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (!apiData) return (
+
+ No data
+
+ );
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/ExplosionEvents.jsx b/client/src/components/Widgets/ExplosionEvents.jsx
index ddd8044..2ddbf51 100644
--- a/client/src/components/Widgets/ExplosionEvents.jsx
+++ b/client/src/components/Widgets/ExplosionEvents.jsx
@@ -2,9 +2,10 @@ import { useMemo } from 'react';
import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
import { useColorMode } from "@/components/ui/color-mode.jsx";
import Plot from 'react-plotly.js';
-import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
+import VdapApi from '../../hooks/useVdapApi.js';
+import useBuildUrl from "@/hooks/useBuildUrl.js";
-export default function ExplosionEvents({ settings }) {
+export default function ExplosionEvents({ settings, widgetID }) {
// Plotly expects 'linear' for normal axes
const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear';
@@ -12,8 +13,11 @@ export default function ExplosionEvents({ settings }) {
const { colorMode } = useColorMode();
const isDark = colorMode === 'dark';
+ // Generate url with input dates, default to 7 day range
+ const url = useBuildUrl('/api/v2/explosion_events', settings?.startDate, settings?.endDate);
+
// Clean, single-line data fetching
- const { data: apiData, isLoading, error } = VdapApi('/api/v2/explosion_events?start_date=2025-05-01&end_date=2025-05-30');
+ const { data: apiData, isLoading, error } = VdapApi(url);
// 1. Build the Traces (Data parsing)
const plotTraces = useMemo(() => {
diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx
new file mode 100644
index 0000000..48451a7
--- /dev/null
+++ b/client/src/components/Widgets/IvansAlerts.jsx
@@ -0,0 +1,204 @@
+import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react";
+import {useEffect, useMemo, useCallback } from 'react';
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import VdapApi from '../../hooks/useVdapApi.js';
+import useBuildUrl from "@/hooks/useBuildUrl.js";
+
+export default function IvansAlerts({ settings, widgetID }) {
+ console.log("Ivans settings: ", settings, "Ivans widgetID: ", widgetID);
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+
+ // find this specific widgets object in the state list of active/displayed widgets and extract its uuid
+ const thisWidget = useDashboardStore(useCallback((state) =>
+ state.widgets.find((widget) => widget.id === widgetID), [widgetID]
+ ));
+
+ // build url, default is 7 days past
+ const url = useBuildUrl('/api/v2/comm_coor_ivans', settings?.startDate, settings?.endDate);
+
+ // VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
+ const {data, isLoading, error} = VdapApi(url);
+
+ // sort the apiData
+ const some_data = useMemo(() => {
+ if (data?.length === 0) return [];
+ const sorts = settings?.activeSorts;
+
+ if (sorts?.volcanoSort && sorts?.timeSort === "asc") return [...data].sort(ascTimeVolcanoSort);
+ else if (sorts?.volcanoSort && sorts?.timeSort === "desc") return [...data].sort(descTimeVolcanoSort);
+ else if (sorts?.timeSort === "asc") return [...data].sort(ascTimeSort);
+ else return [...data].sort(descTimeSort);
+
+ }, [data, settings]);
+
+ // copy data to clipboard in formatted way when user clicks on copy btn in widget settings
+ useEffect(() => {
+ if (settings?.copyToClipboard === true) {
+ const dataText = some_data.map((item) => {
+ return `${item.volcano_name}\n` +
+ `Report Number: ${item.report_num}\n` +
+ `Send Time: ${item.send_time}\n` +
+ `Preparer: ${item.preparer}\n` +
+ `Summary:\n${item.summary}\n`;
+ }).join('\n\n');
+ navigator.clipboard.writeText(dataText)
+ .then(() => {
+ alert('Copied to clipboard!');
+ })
+ .catch((err => {
+ console.error('Failed to copy: ', err);
+ }));
+ updateSettings(widgetID, {copyToClipboard: false});
+ }
+ }, [settings, some_data, widgetID, updateSettings]);
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (some_data.length === 0) return (
+
+ No flight level data available for the selected dates.
+
+ );
+
+ return (
+
+ {some_data.map((item, index) => {
+ if (thisWidget?.size === "large") return (
+
+ {/* Using context to dynamically change the border radii of the triggers */}
+
+ {(collapsible) => (
+ <>
+
+
+
+ {item.volcano_name}
+ {item.send_time}
+
+
+ {item.report_num}
+
+
+
+
+ {/* This box needs to be at h="100%" for the pdf toolbar to appear */}
+
+
+ {item?.summary}
+ Preparer: {item?.preparer}
+
+ {item?.files?.map((file, index) => {
+ return (
+
+
+
+ {file?.name}
+
+
+ Open
+
+
+
+
+
+ )})}
+
+
+ >
+ )}
+
+
+ )
+ else if (thisWidget?.size === "small") return (
+
+
+ {item.volcano_name}
+ {item.send_time}
+
+
+ {item.report_num}
+
+
+ )
+ })}
+
+ );
+}
+
+
+function ascTimeVolcanoSort(a, b) {
+ if (a.volcano_name < b.volcano_name) return -1
+ else if (a.volcano_name > b.volcano_name) return 1
+ else return ascTimeSort(a, b);
+}
+
+function descTimeVolcanoSort(a, b) {
+ if (a.volcano_name < b.volcano_name) return -1
+ else if (a.volcano_name > b.volcano_name) return 1
+ else return descTimeSort(a, b);
+}
+
+function ascTimeSort(a, b) {
+ const aTime = new Date(a.send_time);
+ const bTime = new Date(b.send_time);
+
+ if (aTime > bTime) return 1;
+ if (aTime < bTime) return -1;
+ return 0;
+}
+
+function descTimeSort(a, b) {
+ const aTime = new Date(a.send_time);
+ const bTime = new Date(b.send_time);
+
+ if (aTime > bTime) return -1;
+ if (aTime < bTime) return 1;
+ return 0;
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/IvansAlerts.stories.jsx b/client/src/components/Widgets/IvansAlerts.stories.jsx
new file mode 100644
index 0000000..a79dfcd
--- /dev/null
+++ b/client/src/components/Widgets/IvansAlerts.stories.jsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { Box } from '@chakra-ui/react';
+import { http, HttpResponse } from 'msw'; // Import MSW
+import IvansAlerts from './IvansAlerts';
+
+export default {
+ title: 'Dashboard Widgets/IVANS Activity',
+ component: IvansAlerts,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+const mockData = [
+ {
+ id: 279,
+ volcano_number: 250030,
+ report_num: "2026-01",
+ report_date: "2026-05-15T05:11:53Z",
+ preparer: "charpel",
+ reviewer: "jlwnstrn",
+ send_time: "2026-05-15T05:12:01Z",
+ volcano_name: "Central Bismarck Sea",
+ summary: "A M5.4 earthquake occurred on 14 May 2026 at 17:13 (UTC) during the ongoing eruption of the submarine Central Bismarck Sea volcano that began 8 May 2026. The volcano's current activity does not threaten any populated areas, but today’s earthquake indicates that eruptive activity will continue or increase.",
+ files: [
+ {
+ name: "279-250030-Central Bismarck Sea-2026-01.pdf",
+ url: "https://vdap.org/ivans/sitrep/279-250030-Central Bismarck Sea-2026-01.pdf",
+ type: "pdf"
+ }
+ ]
+ },
+ {
+ id: 277,
+ volcano_number: 268010,
+ report_num: "2026-01",
+ report_date: "2026-05-08T22:05:44Z",
+ preparer: "hwright",
+ reviewer: "dramsey",
+ send_time: "2026-05-08T22:05:52Z",
+ volcano_name: "Dukono",
+ summary: "At 07:41 local time on 8 May 2026, an explosion of persistently active Dukono volcano occurred, producing an ash plume that rose over 15 km (52,000 feet) above sea level and ejecting ballistic blocks of lava beyond the crater rim. The eruption killed three tourists who were hiking within the official exclusion zone; other hikers were rescued and returned to safety.",
+ files: [
+ {
+ name: "277-268010-Dukono-2026-01.pdf",
+ url: "https://vdap.org/ivans/sitrep/277-268010-Dukono-2026-01.pdf",
+ type: "pdf"
+ }
+ ]
+ },
+ {
+ id: 275,
+ volcano_number: 273030,
+ report_num: "2026-02",
+ report_date: "2026-05-03T05:28:12Z",
+ preparer: "dramsey",
+ reviewer: "hwright",
+ send_time: "2026-05-03T05:28:20Z",
+ volcano_name: "Mayon",
+ summary: "At around 5:30 PM local time on May 2, 2026, a pyroclastic flow (cloud of searing ash and rock particles) from erupting Mayon Volcano traveled around 4 km (~2.5 miles) down the southwestern slope of the volcano and produced an ash plume that drifted to the west-southwest. At least 52 villages in various towns in Albay province were heavily affected by thick ash fall, reducing visibility to near zero and making travel extremely difficult. There were no reports of casualties and the volcano alert level remained at Level 3 (on a 0-5 level scale).",
+ files: [
+ {
+ name: "275-273030-Mayon-2026-02.pdf",
+ url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
+ type: "pdf"
+ },
+ {
+ name: "275-273030-Mayon-2026-02.pdf",
+ url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
+ type: "pdf"
+ }
+ ]
+ }
+];
+
+// Scenario LIVE: call our API for real
+export const LivePopulated = {};
+
+// Scenario 1: Successful API Call
+export const Populated = {
+ parameters: {
+ msw: {
+ handlers: [
+ // Intercept the exact URL your component is trying to fetch
+ http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
+ // Return a 200 OK with the mock JSON
+ return HttpResponse.json(mockData);
+ }),
+ ],
+ },
+ },
+};
+
+// Scenario 2: The 500 Server Crash
+export const ServerCrash = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
+ // Return a 500 Error
+ return new HttpResponse(null, { status: 500 });
+ }),
+ ],
+ },
+ },
+};
+
+// Scenario 3: Empty State (API works, but no data returned)
+export const EmptyData = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
+ return HttpResponse.json([]);
+ }),
+ ],
+ },
+ },
+};
\ No newline at end of file
diff --git a/client/src/components/Widgets/PlumeEvents.jsx b/client/src/components/Widgets/PlumeEvents.jsx
new file mode 100644
index 0000000..925a96d
--- /dev/null
+++ b/client/src/components/Widgets/PlumeEvents.jsx
@@ -0,0 +1,143 @@
+import VdapApi from '../../hooks/useVdapApi.js';
+import { useMemo } from 'react';
+import Plot from 'react-plotly.js';
+import {Box, Flex, Spinner, Text} from "@chakra-ui/react";
+import useBuildUrl from "@/hooks/useBuildUrl.js";
+
+export default function PlumeEvents({ settings, widgetID}) {
+ // Build url, defaults to 7 days past
+ const url = useBuildUrl('/api/v2/plume_events', settings?.startDate, settings?.endDate);
+
+ // Retrieve data from api
+ const { data: apiData, isLoading, error } = VdapApi(url);
+
+ const plotTraces = useMemo(() => {
+ if (!apiData || apiData.length === 0) return [];
+
+ // Array of distinct Plotly shapes
+ const Symbolway = [
+ 'circle', 'square', 'diamond',
+ 'triangle-up', 'triangle-down',
+ 'star', 'hexagram', 'pentagon',
+ 'hexagon', 'octagon', 'cross', 'x'
+ ];
+
+ // Sort data descending by plume height so smaller dots draw on top of larger ones
+ const sortedData = [...apiData].sort((a, b) => b.h_avs_m - a.h_avs_m);
+
+ // Efficiently group into Plotly traces by volcano
+ const groupedData = sortedData.reduce((acc, curr) => {
+ const name = curr.volcano_name || 'Unknown';
+ if (!acc[name]) acc[name] = { x: [], y: [] };
+
+ // Map the explosion-specific keys
+ acc[name].x.push(new Date(curr.ts_epoch));
+ acc[name].y.push(curr.h_avs_m);
+ return acc;
+ }, {});
+
+ return Object.keys(groupedData).map((volcanoName, index) => ({
+ name: volcanoName,
+ x: groupedData[volcanoName].x,
+ y: groupedData[volcanoName].y,
+ type: 'scatter',
+ mode: 'markers',
+ marker: {
+ size: 7,
+ line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
+ symbol: Symbolway[index % Symbolway.length],
+ },
+ // Replicating the clean D3/Observable tooltip
+ hovertemplate:
+ `${volcanoName}
` +
+ 'Date (UTC): %{x|%b %d %Y %H:%M}
' +
+ 'Plume Height (m): %{y}'
+ }));
+ }, [apiData]);
+
+ const layout = useMemo(() => {
+
+ return {
+ autosize: true,
+ paper_bgcolor: 'transparent', // Let Chakra UI background show through
+ plot_bgcolor: 'transparent',
+ font: { color: '#d1d5db' }, // gray.300
+ margin: { t: 40, r: 20, l: 20, b: 20 },
+ // title: {
+ // text: '7 Day Consolidated Flight Levels',
+ // font: { size: 16, color: 'white' },
+ // x: 0.05
+ // },
+ xaxis: {
+ title: {
+ text: 'Date (UTC)',
+ font: { color: 'white', size: 12 }
+ },
+ type: 'date',
+ tickformat: '%b %d
%Y', // D3 requested %b %d \n %Y formatting
+ gridcolor: '#4b5563',
+ zerolinecolor: '#4b5563',
+ automargin: true,
+ },
+ yaxis: {
+ title: {
+ text: 'Plume Height',
+ font: { color: 'white', size: 12 }
+ },
+ type: 'linear',
+ gridcolor: '#4b5563',
+ zerolinecolor: '#ffffff',
+ automargin: true,
+ },
+ // legend: {
+ // orientation: 'h',
+ // y: -0.2,
+ // x: 0.5,
+ // xanchor: 'center'
+ // },
+ hovermode: 'closest',
+ shapes: [
+ {
+ type: 'line',
+ x0: 0,
+ x1: 1,
+ xref: 'paper', // Spans entire chart width
+ y0: 0,
+ y1: 0,
+ yref: 'y',
+ line: { color: 'white', width: 1 }
+ }
+ ],
+ };
+ }, []);
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (plotTraces.length === 0) return (
+
+ No data available for current filter.
+
+ );
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx
new file mode 100644
index 0000000..3adbb27
--- /dev/null
+++ b/client/src/components/Widgets/RemoteSensing.jsx
@@ -0,0 +1,217 @@
+import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react";
+import {useEffect, useMemo, useCallback} from 'react';
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import VdapApi from '@/hooks/useVdapApi.js';
+import useBuildUrl from "@/hooks/useBuildUrl.js";
+
+export default function RemoteSensing({ settings, widgetID }) {
+ console.log("RS settings: ", settings, "RS ID: ", widgetID);
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+
+ // Find this specific widgets object in the state list of active & displayed widgets
+ const thisWidget = useDashboardStore(useCallback((state) =>
+ state.widgets.find((widget) => widget.id === widgetID), [widgetID]
+ ));
+
+ // build url, defaults to 7 days past
+ const url = useBuildUrl('/api/v2/remote_sensing', settings?.startDate, settings?.endDate);
+
+ // VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
+ const {data: apiData, isLoading, error} = VdapApi(url);
+
+ // sort apiData
+ const data = useMemo(() => {
+ if (apiData?.length === 0) return [];
+ const sorts = settings?.activeSorts;
+
+ if (sorts?.volcanoSort && sorts?.timeSort === "asc") return [...apiData].sort(ascTimeVolcanoSort);
+ else if (sorts?.volcanoSort && sorts?.timeSort === "desc") return [...apiData].sort(descTimeVolcanoSort);
+ else if (sorts?.timeSort === "asc") return [...apiData].sort(ascTimeSort);
+ else return [...apiData].sort(descTimeSort);
+
+ }, [apiData, settings]);
+
+ // check if user wants to copy all RS
+ useEffect(() => {
+ if(settings?.copyToClipboard === true) {
+ const dataText = data.map((item) => {
+ return `${item.country}, ${item.volcano_name}\n` +
+ `Report Time: ${item.report_time}\n` +
+ `Sender: ${item.sender}\n` +
+ `Summary:\n${item.summary}\n`;
+ }).join('\n\n');
+ navigator.clipboard.writeText(dataText)
+ .then(() => {
+ alert('Copied to clipboard!');
+ })
+ .catch((err => {
+ console.error('Failed to copy: ', err);
+ }));
+ updateSettings(widgetID, {copyToClipboard: false});
+ }
+ }, [settings, data, widgetID, updateSettings]);
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (data.length === 0) return (
+
+ No Remote Sensing data available this week.
+
+ );
+
+ return (
+
+ {data.map((item, index) => {
+ if (thisWidget?.size === "large" || thisWidget?.size === "medium") return (
+
+ {/* Using context to dynamically change the border radii of the triggers */}
+
+ {(collapsible) => (
+ <>
+
+
+
+ {item?.volcano_name}
+ {item?.country}
+
+
+
+ {item?.report_time}
+
+
+
+
+
+ {/* This box needs to be at h="100%" for the pdf toolbar to appear */}
+
+
+ {item?.summary}
+ Source: {item?.sender}
+
+ {item?.files?.map((file, index) => {
+
+ // PDF FILE TYPE
+ if (file?.type === "pdf") return (
+
+
+
+ {file?.name}
+ PDF
+
+
+
+ );
+
+ // IMAGE FILE TYPE
+ else if (file?.type === "image") return (
+
+
+
+ {file?.name}
+ PNG
+
+
+
+
+
+ );
+
+ // DOWNLOADABLE LINK
+ else if (file?.type === "download") return (
+ <>
+
+
+ {file?.name}
+
+
+ >
+ )
+ })}
+
+
+ >
+ )}
+
+
+ )
+ else if (thisWidget?.size === "small") return (
+
+
+ {item?.volcano_name}
+ {item?.country}
+
+
+
+ {item?.report_time}
+
+
+
+ )
+ })}
+
+ );
+}
+
+
+function ascTimeVolcanoSort(a, b) {
+ if (a.volcano_name < b.volcano_name) return -1
+ else if (a.volcano_name > b.volcano_name) return 1
+ else return ascTimeSort(a, b);
+}
+
+function descTimeVolcanoSort(a, b) {
+ if (a.volcano_name < b.volcano_name) return -1
+ else if (a.volcano_name > b.volcano_name) return 1
+ else return descTimeSort(a, b);
+}
+
+function ascTimeSort(a, b) {
+ const aTime = new Date(a.report_time);
+ const bTime = new Date(b.report_time);
+
+ if (aTime > bTime) return 1;
+ if (aTime < bTime) return -1;
+ return 0;
+}
+
+function descTimeSort(a, b) {
+ const aTime = new Date(a.report_time);
+ const bTime = new Date(b.report_time);
+
+ if (aTime > bTime) return -1;
+ if (aTime < bTime) return 1;
+ return 0;
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/SeismicEvents.jsx b/client/src/components/Widgets/SeismicEvents.jsx
new file mode 100644
index 0000000..d01dd75
--- /dev/null
+++ b/client/src/components/Widgets/SeismicEvents.jsx
@@ -0,0 +1,143 @@
+import VdapApi from '../../hooks/useVdapApi.js';
+import { useMemo } from 'react';
+import Plot from 'react-plotly.js';
+import {Box, Flex, Spinner, Text} from "@chakra-ui/react";
+import useBuildUrl from "@/hooks/useBuildUrl.js";
+
+export default function SeismicEvents({ settings, widgetID}) {
+ // build url, defaults to 7 days past
+ const url = useBuildUrl('/api/v2/seismic_events', settings?.startDate, settings?.endDate);
+
+ // retrieve data from api
+ const { data: apiData, isLoading, error } = VdapApi(url);
+
+ const plotTraces = useMemo(() => {
+ if (!apiData || apiData.length === 0) return [];
+
+ // Array of distinct Plotly shapes
+ const Symbolway = [
+ 'circle', 'square', 'diamond',
+ 'triangle-up', 'triangle-down',
+ 'star', 'hexagram', 'pentagon',
+ 'hexagon', 'octagon', 'cross', 'x'
+ ];
+
+ // Sort data descending by seismic events count so smaller dots draw on top of larger ones
+ const sortedData = [...apiData].sort((a, b) => b.number_events - a.number_events);
+
+ // Efficiently group into Plotly traces by volcano
+ const groupedData = sortedData.reduce((acc, curr) => {
+ const name = curr.volcano_name || 'Unknown';
+ if (!acc[name]) acc[name] = { x: [], y: [] };
+
+ // Map the seismic-specific keys
+ acc[name].x.push(new Date(curr.ts_epoch));
+ acc[name].y.push(curr.number_events);
+ return acc;
+ }, {});
+
+ return Object.keys(groupedData).map((volcanoName, index) => ({
+ name: volcanoName,
+ x: groupedData[volcanoName].x,
+ y: groupedData[volcanoName].y,
+ type: 'scatter',
+ mode: 'markers',
+ marker: {
+ size: 7,
+ line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
+ symbol: Symbolway[index % Symbolway.length],
+ },
+ // Replicating the clean D3/Observable tooltip
+ hovertemplate:
+ `${volcanoName}
` +
+ 'Date (UTC): %{x|%b %d %Y %H:%M}
' +
+ 'Number of Seismic Events: %{y}'
+ }));
+ }, [apiData]);
+
+ const layout = useMemo(() => {
+
+ return {
+ autosize: true,
+ paper_bgcolor: 'transparent', // Let Chakra UI background show through
+ plot_bgcolor: 'transparent',
+ font: { color: '#d1d5db' }, // gray.300
+ margin: { t: 40, r: 20, l: 20, b: 20 },
+ // title: {
+ // text: '',
+ // font: { size: 16, color: 'white' },
+ // x: 0.05
+ // },
+ xaxis: {
+ title: {
+ text: 'Date (UTC)',
+ font: { color: 'white', size: 12 }
+ },
+ type: 'date',
+ tickformat: '%b %d
%Y', // D3 requested %b %d \n %Y formatting
+ gridcolor: '#4b5563',
+ zerolinecolor: '#4b5563',
+ automargin: true,
+ },
+ yaxis: {
+ title: {
+ text: 'Seismic Count',
+ font: { color: 'white', size: 12 }
+ },
+ type: 'linear',
+ gridcolor: '#4b5563',
+ zerolinecolor: '#ffffff',
+ automargin: true,
+ },
+ // legend: {
+ // orientation: 'h',
+ // y: -0.2,
+ // x: 0.5,
+ // xanchor: 'center'
+ // },
+ hovermode: 'closest',
+ shapes: [
+ {
+ type: 'line',
+ x0: 0,
+ x1: 1,
+ xref: 'paper', // Spans entire chart width
+ y0: 0,
+ y1: 0,
+ yref: 'y',
+ line: { color: 'white', width: 1 }
+ }
+ ],
+ };
+ }, []);
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (plotTraces.length === 0) return (
+
+ No data available for current filter.
+
+ );
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/TravelGanttChart.jsx b/client/src/components/Widgets/TravelGanttChart.jsx
new file mode 100644
index 0000000..caa2384
--- /dev/null
+++ b/client/src/components/Widgets/TravelGanttChart.jsx
@@ -0,0 +1,92 @@
+import { Gantt, Willow } from "@svar-ui/react-gantt";
+import "@svar-ui/react-gantt/all.css";
+import { useMemo } from "react";
+import {Avatar, Flex, Text} from "@chakra-ui/react";
+
+export default function TravelGanttChart() {
+
+ const upcoming_travel = useMemo(() => [
+ {
+ id: 20,
+ text: "Guatemala",
+ start: new Date(2026, 9, 21),
+ end: new Date(2026, 9, 29),
+ duration: 8,
+ participants: ["Chris Hight", "Angie Diefenbach"],
+ type: "travel",
+ },
+ {
+ id: 47,
+ text: "Venuatu",
+ start: new Date(2026, 10, 12),
+ end: new Date(2026, 10, 15),
+ duration: 3,
+ participants: ["Chris Hight"],
+ type: "travel",
+ },
+ {
+ id: 22,
+ text: "Costa Rica",
+ start: new Date(2027, 1, 1),
+ end: new Date(2027, 1, 10),
+ duration: 9,
+ participants: ["Chris Hight"],
+ type: "travel",
+ },
+ ], []);
+
+ const columns = [
+ { id: "start", header: "Start Date", width: 100, height: 200, align: "left" },
+ { id: "duration", header: "Duration", width: 100, height: 200, align: "left" },
+ { id: "participants", header: "Participants", width: 150, height: 200, align: "center", cell: AvatarCell, flexgrow: 1 },
+ ]
+
+ return (
+
+
+
+
+
+ )
+}
+
+// assume each user has access to the full list of VDAP users
+
+const users = [
+ {
+ fullname: "Chris Hight",
+ firstname: "Chris",
+ lastname: "Hight",
+ avatar: undefined
+ },
+ {
+ fullname: "Angie Diefenbach",
+ firstname: "Chris",
+ lastname: "Hight",
+ avatar: undefined
+ },
+ {
+ fullname: "Venessa Kuchenik",
+ firstname: "Venessa",
+ lastname: "Kuchenik",
+ avatar: undefined
+ },
+]
+
+function AvatarCell({ row }) {
+ const travelers = row.participants.map((r) => {
+ return users.find((u) => u.fullname === r);
+ });
+
+ return (
+
+ {travelers.map((traveler) => (
+
+
+
+ {traveler.fullname}
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/VaaActivity7Day.jsx b/client/src/components/Widgets/VaaActivity7Day.jsx
index 85d35c1..a6bb48c 100644
--- a/client/src/components/Widgets/VaaActivity7Day.jsx
+++ b/client/src/components/Widgets/VaaActivity7Day.jsx
@@ -3,7 +3,7 @@ import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
import Plot from 'react-plotly.js';
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
-export default function VaaActivity7Day({ settings }) {
+export default function VaaActivity7Day({ settings, widgetID }) {
console.log("3. Chart received settings:", settings);
// Plotly expects 'linear' for normal axes
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
diff --git a/client/src/components/Widgets/VaaMap.jsx b/client/src/components/Widgets/VaaMap.jsx
new file mode 100644
index 0000000..1ea059e
--- /dev/null
+++ b/client/src/components/Widgets/VaaMap.jsx
@@ -0,0 +1,212 @@
+import { useRef, useMemo, useEffect } from "react";
+import {MapContainer, useMap, TileLayer, Polygon, Tooltip} from "react-leaflet";
+import "leaflet/dist/leaflet.css";
+import {Flex, Spinner, Text} from "@chakra-ui/react";
+import L from 'leaflet';
+
+export default function VaaMap({ settings, widgetID}) {
+ const mapRef = useRef(null);
+
+ // TEST DATA
+ const mock_data = useMemo( () =>
+ [
+ {
+ "vaa_datetime": "2026-08-18T18:01:00Z",
+ "vnum": 342090,
+ "volc_name": "Fuego",
+ "volc_elev_m": 3763,
+ "vaac_location": "Washington",
+ "obs_va_datetime": "2026-08-18T17:30:00Z",
+ "obs_va_epoch": 1787074200000,
+ "aviation_color_code": null,
+ "obs_fl": "140",
+ "obs_fl_dir": "SW",
+ "obs_h_asl_m": 4267.0,
+ "obs_h_avs_m": 504.0,
+ "projections":
+ {
+ "six": "25/2030Z SFC/FL160 N1446 W09202 - N1428 W09052 - N1423 W09200 - N1446 W09202",
+ "twelve": "26/0230Z SFC/FL160 N1444 W09203 - N1428 W09052 - N1422 W09201 - N1444 W09203",
+ "eighteen": "26/0830Z SFC/FL160 N1441 W09204 - N1428 W09052 - N1419 W09202 - N1441 W09204"
+ }
+ },
+ {
+ "vaa_datetime": "2026-08-25T01:01:00Z",
+ "vnum": 282080,
+ "volc_name": "Sabancaya",
+ "volc_elev_m": 1117,
+ "vaac_location": "Tokyo",
+ "obs_va_datetime": "2026-08-25T00:50:00Z",
+ "obs_va_epoch": 1787619000000,
+ "aviation_color_code": null,
+ "obs_fl": "180",
+ "obs_fl_dir": null,
+ "obs_h_asl_m": 5486,
+ "obs_h_avs_m": 4369,
+ "projections":
+ {
+ "six": "14/0200Z SFC/FL250 S1532 W07036 - S1552 W07029 - S1613 W07035 - S1546 W07150 - S1532 W07036 ",
+ "twelve": "14/0800Z SFC/FL250 S1513 W07045 - S1527 W07033 - S1551 W07029 - S1544 W07150 - S1513 W07045 ",
+ "eighteen": "14/1400Z SFC/FL250 S1511 W07044 - S1531 W07028 - S1550 W07027 - S1546 W07151 - S1511 W07044 "
+ },
+ },
+ ]
+ , []);
+
+ const {data, isLoading, error} = useMemo(() => {
+ const data = settings?.data?.sort((a, b) => new Date(b.vaa_datetime) - new Date(a.vaa_datetime));
+ const isLoading = settings?.isLoading;
+ const error = settings?.error;
+ return {data, isLoading, error};
+ }, [settings.data, settings.isLoading, settings.error]);
+
+ // convert Degree & Minutes (DM) to Decimal Degrees (DD)
+ const polygons = useMemo(() => {
+ if (!mock_data) return []; // TODO: replace with real data
+ return mock_data.map((vaa) => { // TODO: replace with real data
+ const six_hrs = convertDMtoDD(vaa.projections.six);
+ const twelve_hrs = convertDMtoDD(vaa.projections.twelve);
+ const eighteen_hrs = convertDMtoDD(vaa.projections.eighteen);
+ return {...vaa, projections: [six_hrs, twelve_hrs, eighteen_hrs]};
+ })
+ }, [mock_data]);
+
+ const lineColors= {
+ 0: { color: 'red' },
+ 1: { color: 'purple' },
+ 2: { fillColor: 'blue' },
+ }
+
+ const hourCode = {
+ 0: "+6 hours",
+ 1: "+12 hours",
+ 2: "+18 hours",
+ }
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (data?.length === 0) return (
+
+ No VAA data available.
+
+ );
+
+ return (
+
+
+ {polygons?.map((polygon) => {
+ return polygon?.projections?.map((coordinates, index) =>
+
+
+
+
+ {polygon?.volc_name}
+ {hourCode[index]}
+
+
+ VAA Datetime:
+ {polygon?.vaa_datetime}
+
+
+ VAAC Location:
+ {polygon?.vaac_location}
+
+
+ Height From Vent:
+ {polygon?.obs_h_avs_m} (m)
+
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+function convertDMtoDD(DMString) {
+ const cleaned_DMString = DMString.trim().replace(/-/g, "").split(/\s+/);
+
+ const coordinates = cleaned_DMString.slice(2).map((coordinate) => {
+ const direction = coordinate[0];
+ if (direction === "N" || direction === "S") { // 4 digits long
+ const lat = Number(coordinate.slice(1, 3)) + Number((Number(coordinate.slice(3, 5)) / 60).toFixed(5));
+ return direction === "S" ? -lat : lat; // south has negative coordinates
+ } else if (direction === "E" || direction === "W") { // 5 digits long
+ const long = Number(coordinate.slice(1, 4)) + Number((Number(coordinate.slice(4, 6)) / 60).toFixed(5));
+ return direction === "W" ? -long : long; // west has negative coordinates
+ }
+ });
+
+ // pair up lat and long into their own array pairs
+ let polygon = [];
+ for (let i = 0; i < coordinates.length; i+=2) polygon.push([coordinates[i], coordinates[i+1]]);
+ return polygon;
+}
+
+function ZoomToPolygon({ polygons, settings }) {
+ const map = useMap();
+
+ useEffect(() => {
+ let targetBounds;
+ let default_vaa_object;
+ const selectedVAA = settings?.selected;
+ const defaultVAA = settings?.defaultVAA;
+
+ // selectedVAA and defualtVAA are filled in dynamically so they may not always be defined, defualt to hardcoded CVO coordinates
+ if (!selectedVAA && !defaultVAA) {
+ targetBounds = [settings.position];
+ // console.log("zooming to cvo");
+ }
+
+ // if defaultVAA is defined and user hasn't selected a vaa to pan to, then use defaultVAA coordinates
+ else if (!selectedVAA) {
+ // console.log("zooming to", defaultVAA);
+ default_vaa_object = polygons.find((vaa) => vaa.volc_name === defaultVAA);
+ // if for whatever reason defaultVAA isn't found in list of vaa data, default to hardcoded CVO coordinates
+ default_vaa_object ?
+ targetBounds = default_vaa_object?.projections?.[0] :
+ targetBounds = [settings?.position];
+ }
+
+ else if (selectedVAA) {
+ // console.log("zooming to", selectedVAA);
+ const vaa_object = polygons.find((vaa) => vaa.volc_name === selectedVAA);
+ // if selected VAA is not found (say user just started typing in search) will default to defaultVAA, unless defualtVAA is also undefined
+ vaa_object ?
+ targetBounds = vaa_object?.projections?.[0] :
+ (default_vaa_object ?
+ targetBounds = default_vaa_object?.projections?.[0] :
+ targetBounds = [settings?.position]);
+ }
+
+ if (targetBounds) {
+ try {
+ const bounds = L.latLngBounds(targetBounds);
+ if (bounds.isValid()) map.fitBounds(bounds, {padding: [50, 50], animate: true, maxZoom: 8});
+ } catch (err) {
+ console.warn('Invalid bounds structure passed to fitBounds:', targetBounds, err);
+ }
+ }
+ }, [map, polygons, settings.position, settings.selected, settings.defaultVAA]);
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/AlertChangeSettings.jsx b/client/src/components/Widgets/settings/AlertChangeSettings.jsx
new file mode 100644
index 0000000..bc1103b
--- /dev/null
+++ b/client/src/components/Widgets/settings/AlertChangeSettings.jsx
@@ -0,0 +1,36 @@
+import {Flex, Slider, Switch} from "@chakra-ui/react";
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+
+export default function IvansAlertsSettings({ widgetId, currentSettings }) {
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+
+ return (
+
+ {/* SLIDER DATE RANGE SELECTION */}
+ {
+ const day = new Date(Date.now() - (e.value[0])*86400000).toLocaleDateString('en-CA');
+ console.log("new start: ", day);
+ updateSettings(widgetId, {...currentSettings, startDate: day});
+ }}
+ >
+ {currentSettings?.endDate} to {currentSettings?.startDate}
+
+
+
+
+
+
+ today
+ 1 week ago
+ 2 weeks ago
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/CommCoorReportSettings.jsx b/client/src/components/Widgets/settings/CommCoorReportSettings.jsx
new file mode 100644
index 0000000..3edf232
--- /dev/null
+++ b/client/src/components/Widgets/settings/CommCoorReportSettings.jsx
@@ -0,0 +1,83 @@
+import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import {useState} from "react";
+
+export default function CommCoorReportSettings({ widgetId, currentSettings }) {
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+ const [startError, setStartError] = useState(null);
+ const [endError, setEndError] = useState(null);
+
+ const handleCopyToClipboard = () => {
+ updateSettings(widgetId, {copyToClipboard: true});
+ }
+
+ const handleDownloadReport = () => {
+ updateSettings(widgetId, {downloadReport: true});
+ }
+
+ return (
+
+ {/* COPY TO CLIPBOARD */}
+
+
+ {/* DOWNLOAD REPORT */}
+
+
+ {/* SELECTING DATE RANGE */}
+
+
+
+
+ Start Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, startDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
+ else setStartError("");
+ }}
+ />
+ {startError && {startError}}
+
+
+
+
+
+
+ End Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, endDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
+ else setEndError("");
+ }}
+ />
+ {endError && {endError}}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/ExplosionEventsSettings.jsx b/client/src/components/Widgets/settings/ExplosionEventsSettings.jsx
index 401cb68..20a509a 100644
--- a/client/src/components/Widgets/settings/ExplosionEventsSettings.jsx
+++ b/client/src/components/Widgets/settings/ExplosionEventsSettings.jsx
@@ -1,8 +1,11 @@
-import { Box, Text, Switch } from "@chakra-ui/react";
+import {Box, Text, Switch, Flex, Field, Input} from "@chakra-ui/react";
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
+import {useState} from "react";
export default function ExplosionEventsSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+ const [startError, setStartError] = useState(null);
+ const [endError, setEndError] = useState(null);
// 1. Convert the saved string into a boolean for the toggle UI
const isSymlog = currentSettings?.yAxisScale === 'log';
@@ -18,20 +21,75 @@ export default function ExplosionEventsSettings({ widgetId, currentSettings }) {
};
return (
-
- Y-Axis Settings
+
+ {/* Y-AXIS SETTING*/}
+ Y-Axis Settings
-
-
-
-
-
- Use Log Scale
-
-
+
+
+
+
+
+ Use Log Scale
+
+
+ {/* DATE RANGE SELECTION */}
+
+
+
+ Start Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, startDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
+ else setStartError("");
+ }}
+ />
+ {startError && {startError}}
+
+
+
+
+
+
+ End Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, endDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
+ else setEndError("");
+ }}
+ />
+ {endError && {endError}}
+
+
+
+
);
}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/IvansAlertsSettings.jsx b/client/src/components/Widgets/settings/IvansAlertsSettings.jsx
new file mode 100644
index 0000000..a4915f7
--- /dev/null
+++ b/client/src/components/Widgets/settings/IvansAlertsSettings.jsx
@@ -0,0 +1,108 @@
+import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
+import {useDashboardStore} from "../../../store/useDashboardStore.jsx";
+import {useState} from "react";
+
+export default function IvansAlertsSettings({ widgetId, currentSettings }) {
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+ const [startError, setStartError] = useState(null);
+ const [endError, setEndError] = useState(null);
+
+ const handleCopyToClipboard = () => {
+ updateSettings(widgetId, {copyToClipboard: true});
+ }
+ const handleVolcanoSort = (details) => {
+ const isSort = currentSettings?.activeSorts?.volcanoSort === true;
+ updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, volcanoSort: !isSort }});
+ }
+ const handleTimeSort = (details) => {
+ const sort = currentSettings?.activeSorts?.timeSort === "desc" ? "asc" : "desc";
+ updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, timeSort: sort }});
+ }
+
+ return (
+
+ {/* COPY TO CLIPBOARD */}
+
+
+ {/* SORTS */}
+
+
+
+
+
+ Sort Oldest to Newest
+
+
+
+
+
+
+ Sort by Volcano
+
+
+ {/* SELECTING DATE RANGE */}
+
+
+
+
+ Start Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, startDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
+ else setStartError("");
+ }}
+ />
+ {startError && {startError}}
+
+
+
+
+
+
+ End Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, endDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
+ else setEndError("");
+ }}
+ />
+ {endError && {endError}}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/PlumeEventsSettings.jsx b/client/src/components/Widgets/settings/PlumeEventsSettings.jsx
new file mode 100644
index 0000000..0c2bca2
--- /dev/null
+++ b/client/src/components/Widgets/settings/PlumeEventsSettings.jsx
@@ -0,0 +1,66 @@
+import {Input, Field, Text, Flex} from "@chakra-ui/react";
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import { useState } from "react";
+
+export default function PlumeEventsSettings({ widgetId, currentSettings }) {
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+ const [startError, setStartError] = useState(null);
+ const [endError, setEndError] = useState(null);
+
+ return (
+
+
+
+
+ Start Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, startDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
+ else setStartError("");
+ }}
+ />
+ {startError && {startError}}
+
+
+
+
+
+
+ End Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, endDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
+ else setEndError("");
+ }}
+ />
+ {endError && {endError}}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/RemoteSensingSettings.jsx b/client/src/components/Widgets/settings/RemoteSensingSettings.jsx
new file mode 100644
index 0000000..15ebb7e
--- /dev/null
+++ b/client/src/components/Widgets/settings/RemoteSensingSettings.jsx
@@ -0,0 +1,108 @@
+import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import {useState} from "react";
+
+export default function RemoteSensingSettings({ widgetId, currentSettings }) {
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+ const [startError, setStartError] = useState(null);
+ const [endError, setEndError] = useState(null);
+
+ const handleCopyToClipboard = () => {
+ updateSettings(widgetId, {copyToClipboard: true});
+ }
+ const handleVolcanoSort = (details) => {
+ const isSort = currentSettings?.activeSorts?.volcanoSort === true;
+ updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, volcanoSort: !isSort }});
+ }
+ const handleTimeSort = (details) => {
+ const sort = currentSettings?.activeSorts?.timeSort === "desc" ? "asc" : "desc";
+ updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, timeSort: sort }});
+ }
+
+ return (
+
+ {/* COPYING TO CLIPBOARD */}
+
+
+ {/* SORTING */}
+
+
+
+
+
+ Sort Oldest to Newest
+
+
+
+
+
+
+ Sort by Volcano
+
+
+ {/* SELECTING DATE RANGE */}
+
+
+
+
+ Start Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, startDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
+ else setStartError("");
+ }}
+ />
+ {startError && {startError}}
+
+
+
+
+
+
+ End Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, endDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
+ else setEndError("");
+ }}
+ />
+ {endError && {endError}}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/SeismicEventsSettings.jsx b/client/src/components/Widgets/settings/SeismicEventsSettings.jsx
new file mode 100644
index 0000000..aa53d9e
--- /dev/null
+++ b/client/src/components/Widgets/settings/SeismicEventsSettings.jsx
@@ -0,0 +1,66 @@
+import {Input, Field, Text, Flex} from "@chakra-ui/react";
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import { useState } from "react";
+
+export default function SeismicEventsSettings({ widgetId, currentSettings }) {
+ const [startError, setStartError] = useState(null);
+ const [endError, setEndError] = useState(null);
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+
+ return (
+
+
+
+
+ Start Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, startDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
+ else setStartError("");
+ }}
+ />
+ {startError && {startError}}
+
+
+
+
+
+
+ End Date
+
+
+ {
+ const value = e.target.value;
+ updateSettings(widgetId, {...currentSettings, endDate: value});
+
+ const dateSelected = new Date(value);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
+ else setEndError("");
+ }}
+ />
+ {endError && {endError}}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/VaaMapSettings.jsx b/client/src/components/Widgets/settings/VaaMapSettings.jsx
new file mode 100644
index 0000000..15fe27f
--- /dev/null
+++ b/client/src/components/Widgets/settings/VaaMapSettings.jsx
@@ -0,0 +1,85 @@
+import {useMemo, useEffect, useState} from "react";
+import VdapApi from "@/hooks/useVdapApi.js";
+import useBuildUrl from "@/hooks/useBuildUrl.js";
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import {Combobox, createListCollection, Portal} from "@chakra-ui/react";
+
+export default function VaaMapSettings({widgetId, currentSettings}) {
+ const [inputValue, setInputValue] = useState("");
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+
+ const url = useBuildUrl("api/v2/comm_coor_vaa", currentSettings?.startDate, currentSettings?.endDate);
+ const {data, isLoading, error} = VdapApi(url);
+
+ // retrieving only the latest VAA for each volcano (resultant has no order)
+ const latest_vaas = useMemo(() => {
+ if (!data || !Array.isArray(data)) return undefined;
+ // retrieves last occurance of vaa, so the vaa's need to stay in rev-chrono order here to work
+ return [...new Map(data.map(item => [item['volc_name'], item])).values()];
+ }, [data]);
+
+ useEffect(() => {
+ if (!latest_vaas) updateSettings(widgetId, {isLoading: isLoading, error: error});
+ else updateSettings(widgetId, {data: latest_vaas, isLoading: isLoading, error: error});
+ }, [updateSettings, widgetId, latest_vaas, isLoading, error]);
+
+ // find volc_name of last issued vaa, map will default to this ash polygon
+ const default_vaa = useMemo(() => {
+ if (!latest_vaas || latest_vaas.length === 0) return undefined;
+ const chrono_order = latest_vaas.sort((a, b) => new Date(b.vaa_datetime) - new Date(a.vaa_datetime));
+ // console.log(chrono_order);
+ // console.log(chrono_order[0].volc_name);
+ return chrono_order[0].volc_name;
+ }, [latest_vaas]);
+
+ // set default vaa in settings
+ useEffect(() => {
+ if (default_vaa) updateSettings(widgetId, {defaultVAA: default_vaa});
+ }, [default_vaa, updateSettings, widgetId]);
+
+
+ // filtering based on what user types in search bar
+ const filtered_vaas = useMemo(() => {
+ if (!inputValue.trim()) return latest_vaas;
+ return latest_vaas?.filter((item) => item.volc_name.toLowerCase().includes(inputValue.toLowerCase()));
+ }, [latest_vaas, inputValue]);
+
+ const vaa_collection = useMemo(() => {
+ return createListCollection({
+ items: filtered_vaas?.sort((a, b) => a.volc_name.localeCompare(b.volc_name)),
+ itemToString: (item) => item.volc_name,
+ itemToValue: (item) => item.volc_name,
+ });
+ }, [filtered_vaas]);
+
+ // update state w/ selected vaa so map zooms to polygon
+ useEffect(() => {
+ updateSettings(widgetId, {selected: inputValue});
+ }, [updateSettings, widgetId, inputValue]);
+
+
+ return (
+ setInputValue(e.inputValue)}>
+ Select a Volcano
+
+
+
+
+
+
+
+
+
+
+ No VAA's found
+ {vaa_collection.items.map((vaa) =>
+
+ {vaa?.volc_name}
+
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx
index 3ee9d66..93919fa 100644
--- a/client/src/constants/widgetRegistry.jsx
+++ b/client/src/constants/widgetRegistry.jsx
@@ -7,6 +7,13 @@ import { lazy } from 'react';
// w: 4 = one-third screen width
// w: 3 = one-quarter screen width
+// ONE DAY DATE RANGE
+const getToday = () => (new Date()).toLocaleDateString('en-CA')
+
+// ONE WEEK DATE RANGE
+const getStartDate = () => (new Date(Date.now() - 604800000)).toLocaleDateString('en-CA'); // 7 days = 604800000 = 7*24*60*60*1000
+const getEndDate = () => (new Date()).toLocaleDateString('en-CA'); // 'en-CA' formats the date as YYYY-MM-DD
+
export const WIDGET_REGISTRY = {
"vaa_activity": {
name: "VAA 7 Day Activity",
@@ -19,7 +26,9 @@ export const WIDGET_REGISTRY = {
// The baseline data every new VAA widget starts with
defaultSettings: {
- yAxisScale: 'normal' // Options: 'normal' | 'symlog'
+ // startDate: getStartDate(),
+ // endDate: getEndDate(),
+ yAxisScale: 'normal', // Options: 'normal' | 'symlog'
},
allowedSizes: {
@@ -41,9 +50,21 @@ export const WIDGET_REGISTRY = {
name: "IVANS",
category: "Communications",
description: "Feed of recent IVANS reports and reviewer statuses.",
+ component: lazy(() => import('../components/Widgets/IvansAlerts.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/settings/IvansAlertsSettings.jsx')),
+ defaultSettings: {
+ startDate: getStartDate(),
+ endDate: getEndDate(),
+ copyToClipboard: false,
+ activeSorts: {
+ timeSort: "desc",
+ volcanoSort: false
+ }
+ },
allowedSizes: {
- small: { w: 3, h: 12 },
- medium: { w: 6, h: 24 }
+ small: { w: 3, h: 6 },
+ medium: { w: 3, h: 9 },
+ large: { w: 3, h: 12 },
}
},
@@ -51,8 +72,21 @@ export const WIDGET_REGISTRY = {
name: "Remote Sensing",
category: "Remote Sensing",
description: "Feed of recent remote sensing reports.",
+ component: lazy(() => import('../components/Widgets/RemoteSensing.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/Settings/RemoteSensingSettings.jsx')),
+ defaultSettings: {
+ startDate: getStartDate(),
+ endDate: getEndDate(),
+ copyToClipboard: false,
+ activeSorts: {
+ timeSort: "desc",
+ volcanoSort: false
+ }
+ },
allowedSizes: {
- small: { w: 3, h: 12 },
+ small: { w: 3, h: 6 },
+ medium: { w: 3, h: 9 },
+ large: { w: 3, h: 12 },
}
},
@@ -60,9 +94,15 @@ export const WIDGET_REGISTRY = {
name: "Alert Level Changes",
category: "Daily Alerts",
description: "List of 7-day alert level changes with trend arrows.",
+ component: lazy(() => import('../components/Widgets/AlertChanges.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/settings/AlertChangeSettings.jsx')),
+ defaultSettings: {
+ startDate: getStartDate(),
+ endDate: getEndDate(),
+ },
allowedSizes: {
- small: { w: 3, h: 12 },
- large: { w: 9, h: 12 }
+ small: { w: 3, h: 6 },
+ large: { w: 3, h: 12 }
}
},
@@ -78,7 +118,39 @@ export const WIDGET_REGISTRY = {
// The baseline data every new VAA widget starts with
defaultSettings: {
- yAxisScale: 'log' // Options: 'normal' | 'symlog'
+ startDate: getStartDate(),
+ endDate: getEndDate(),
+ yAxisScale: 'normal' // Options: 'normal' | 'symlog'
+ },
+ allowedSizes: {
+ medium: { w: 6, h: 12 }
+ }
+ },
+
+ "plume_events": {
+ name: "Plume Events",
+ category: "Daily Volcanic Activity",
+ description: "Plot of explosion heights over a weeks time.",
+ component: lazy(() => import('../components/Widgets/PlumeEvents.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/settings/PlumeEventsSettings.jsx')),
+ defaultSettings: {
+ startDate: getStartDate(),
+ endDate: getEndDate(),
+ },
+ allowedSizes: {
+ medium: { w: 6, h: 12 }
+ }
+ },
+
+ "seismic_events": {
+ name: "Seismic Events",
+ category: "Daily Volcanic Activity",
+ description: "Plot of number of seismic events over a weeks time.",
+ component: lazy(() => import('../components/Widgets/SeismicEvents.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/settings/SeismicEventsSettings.jsx')),
+ defaultSettings: {
+ startDate: getStartDate(),
+ endDate: getEndDate(),
},
allowedSizes: {
medium: { w: 6, h: 12 }
@@ -88,11 +160,58 @@ export const WIDGET_REGISTRY = {
"llm_al_changes_quad_plot": {
name: "Alert Level Changes Quad Plot",
category: "Computer Science",
- description: "Percentage of True-Positive, True-Negative, False-Positive, and False-Negative changes in all " +
- "alert levels.",
+ description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.",
component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
allowedSizes: {
- small: { w: 3, h: 12 }
+ medium: { w: 3, h: 12 }
+ }
+ },
+
+ "comm_coor_report": {
+ name: "Communication Coordinator Report",
+ category: "Daily Volcanic Activity",
+ description: "Generated Report",
+ component: lazy(() => import('../components/Widgets/CommCoorReport.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/settings/CommCoorReportSettings.jsx')),
+ defaultSettings: {
+ copyToClipboard: false,
+ downloadReport: false,
+ startDate: getStartDate(),
+ endDate: getEndDate(),
+ },
+ allowedSizes: {
+ medium: { w: 3, h: 12 },
+ }
+ },
+
+ "vaa_map": {
+ name: "VAA Polygon Projections Map",
+ category: "Maps",
+ description: "By selecting a volcano, the map will take you to it and show a polygon of the ash in 8, 12, and 18 hours",
+ component: lazy(() => import('../components/Widgets/VaaMap.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/settings/VaaMapSettings.jsx')),
+ defaultSettings: {
+ position: [45.61142, -122.49630], // CVO's coordinates
+ startDate: getToday(),
+ endDate: getToday(),
+ data: [],
+ isLoading: false,
+ isError: undefined,
+ selected: undefined,
+ defaultVAA: undefined,
+ },
+ allowedSizes: {
+ medium: { w: 6, h: 12 }
+ }
+ },
+
+ "travel_gantt_chart": {
+ name: "Travel Gantt Chart",
+ category: "Communications",
+ description: "View upcoming travels in a gantt chart",
+ component: lazy(() => import('../components/Widgets/TravelGanttChart.jsx')),
+ allowedSizes: {
+ medium: { w: 6, h: 12 }
}
}
diff --git a/client/src/css/index.css b/client/src/css/index.css
index 3b6c55d..05f6b29 100644
--- a/client/src/css/index.css
+++ b/client/src/css/index.css
@@ -1,4 +1,8 @@
:root {
/*font-family: Georgia, 'Times New Roman', Times, serif;*/
font-family: "Roboto", sans-serif;
+}
+
+.wx-theme {
+ height: 100%;
}
\ No newline at end of file
diff --git a/client/src/hooks/useBuildUrl.js b/client/src/hooks/useBuildUrl.js
new file mode 100644
index 0000000..3e99e3b
--- /dev/null
+++ b/client/src/hooks/useBuildUrl.js
@@ -0,0 +1,16 @@
+import { useMemo } from "react";
+
+export default function useBuildUrl(base_url, start, end) {
+ return useMemo(() =>{
+ if (start && end)
+ return `${base_url}?start_date=${start}&end_date=${end}`;
+ else {
+ let today = new Date();
+ let sevenDaysAgo = new Date(today);
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
+ sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA');
+ today = today.toLocaleDateString('en-CA');
+ return `${base_url}?start_date=${sevenDaysAgo}&end_date=${today}`;
+ }
+ }, [base_url, start, end]);
+}
\ No newline at end of file
diff --git a/client/src/hooks/useVdapApi.js b/client/src/hooks/useVdapApi.js
index ed369db..850cb09 100644
--- a/client/src/hooks/useVdapApi.js
+++ b/client/src/hooks/useVdapApi.js
@@ -26,11 +26,11 @@ export default function useWidgetApi(endpoint) {
}
const json = await response.json();
- const responseData = Array.isArray(json) ? json : json.data;
+ const responseData = Array.isArray(json) ? json : (json?.data ?? json);
setData(responseData || []);
} catch (err) {
- console.error(`Failed to fetch from ${endpoint}:`, err);
+ console.error(`Failed to parse data from ${endpoint}:`, err);
setError("Failed to load widget data. Is the Django API running?");
} finally {
setIsLoading(false);
diff --git a/client/src/pages/auth/Settings.jsx b/client/src/pages/auth/Settings.jsx
new file mode 100644
index 0000000..0e5129b
--- /dev/null
+++ b/client/src/pages/auth/Settings.jsx
@@ -0,0 +1,131 @@
+import { Flex, Text, Input, Field, Button, Separator, NativeSelect, Image } from '@chakra-ui/react';
+import useAuthStore from "../../store/authStore.js";
+import { useForm } from "react-hook-form";
+import { useState } from "react";
+
+export default function Settings() {
+ const { user } = useAuthStore();
+ const { register, handleSubmit, errors } = useForm();
+ const [value, setValue] = useState(undefined);
+ const updateProfile = useAuthStore((state) => state.updateProfile);
+
+ const onSubmit = (data, event) => {
+ // TODO: check for identical usernames
+ event.preventDefault();
+ console.log(data);
+ if (data.username && data.username.length !== "") updateProfile(data);
+ }
+
+ const ivansRequest = () => {
+ console.log(user.username, "requested ivans permissions");
+ // TODO: log users request in database here which will then display request in web-admin page
+ }
+
+ return (
+
+ );
+}
diff --git a/client/src/pages/auth/WebAdmin.jsx b/client/src/pages/auth/WebAdmin.jsx
new file mode 100644
index 0000000..1c1fa10
--- /dev/null
+++ b/client/src/pages/auth/WebAdmin.jsx
@@ -0,0 +1,234 @@
+import {Flex, Separator, Text, Table, IconButton, NativeSelect, Button} from "@chakra-ui/react";
+import { useMemo } from "react";
+import { useForm, useFieldArray } from "react-hook-form";
+import { Check, X, PenLine } from 'lucide-react';
+import VdapApi from "../../hooks/useVdapApi.js";
+import useBuildUrl from "../../hooks/useBuildUrl.js";
+
+export default function WebAdmin() {
+ // TODO: add below api endpoint
+ // const {data: users, isLoading, error} = VdapApi('/api/v2/vdap_web_users');
+
+ const users = useMemo(() => [
+ {
+ "username": "vkuchenik",
+ "firstName": "Venessa",
+ "lastName": "Kuchenik",
+ "permissions": {
+ "ivans": 2, // 2 => requested permission
+ "rs": 0, // 0 => no permissions and not requested
+ },
+ "team": "Computer Science",
+ "group": "Super User",
+ },
+ {
+ "username": "amack",
+ "firstName": "Adeiline",
+ "lastName": "Mack",
+ "permissions": {
+ "ivans": 2, // 0 => no permissions and not requested
+ "rs": 2, // 1 => has permission
+ },
+ "team": "Remote Sensing",
+ "group": "Member",
+ }
+ ], []);
+
+ const {
+ register,
+ handleSubmit,
+ control,
+ errors,
+ formState: { isDirty }
+ } = useForm({
+ defaultValues: {users: users}
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: "users" // Matches the key in defaultValues
+ })
+
+ const grant = (user, category) => {
+ // 1. post data via api
+ // 2. fetch the new data via api again to force re-render
+ console.log(user.username, "was granted", category, "permission");
+ }
+
+ const deny = (user, category) => {
+ console.log(user.username, "was denied", category, "permission");
+ }
+
+ const users_req_ivans = useMemo(() => {
+ return users.filter(user => user.permissions.ivans === 2);
+ }, [users]);
+
+ const users_req_rs = useMemo(() => {
+ return users.filter(user => user.permissions.rs === 2);
+ }, [users]);
+
+ const onSubmit = (data, event) => {
+ event.preventDefault();
+ console.log(data);
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/store/authStore.js b/client/src/store/authStore.js
index abafe24..9c8ebde 100644
--- a/client/src/store/authStore.js
+++ b/client/src/store/authStore.js
@@ -86,7 +86,22 @@ const useAuthStore = create((set) => ({
} catch (error) {
console.error("Failed to save layout to database", error);
}
- }
+ },
+
+ // updateProfile: async (newProfile) => {
+ // try {
+ // set((state) => ({
+ // user: {
+ // ...state.user,
+ // username: newProfile.username,
+ // }
+ // }));
+ //
+ // await api.post('profile/update/', { profile: newProfile });
+ // } catch (error) {
+ // console.error("Failed to update profile", error);
+ // }
+ // }
}));
export default useAuthStore;
\ No newline at end of file
diff --git a/client/src/store/useDashboardStore.jsx b/client/src/store/useDashboardStore.jsx
index 43803cb..af51da3 100644
--- a/client/src/store/useDashboardStore.jsx
+++ b/client/src/store/useDashboardStore.jsx
@@ -1,109 +1,110 @@
-import { create } from 'zustand';
-import { v4 as uuid } from 'uuid';
-import { WIDGET_REGISTRY } from '../constants/widgetRegistry.jsx';
+import {create} from 'zustand';
+import {v4 as uuid} from 'uuid';
+import {WIDGET_REGISTRY} from '../constants/widgetRegistry.jsx';
// 1. Import your database messenger
import useAuthStore from './authStore';
export const useDashboardStore = create((set, get) => ({
- // ==========================================
- // 1. STATE (The Data)
- // ==========================================
+ // ==========================================
+ // 1. STATE (The Data)
+ // ==========================================
+ isEditing: false,
+ widgets: [], // The master array of what is currently on the screen
+ backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
+
+ // ==========================================
+ // 2. ACTIONS (The Logic)
+ // ==========================================
+
+ enterEditMode: () => set({
+ isEditing: true,
+ backupWidgets: get().widgets // 📸 Take a snapshot of current positions
+ }),
+
+ cancelEdit: () => set({
isEditing: false,
- widgets: [], // The master array of what is currently on the screen
- backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
+ widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
+ }),
- // ==========================================
- // 2. ACTIONS (The Logic)
- // ==========================================
+ saveEdit: async () => {
+ // 1. Grab the exact current state of the grid
+ const currentLayout = get().widgets;
- enterEditMode: () => set({
- isEditing: true,
- backupWidgets: get().widgets // 📸 Take a snapshot of current positions
- }),
+ // 2. Lock the UI immediately so the user feels zero lag
+ set({isEditing: false, backupWidgets: []});
- cancelEdit: () => set({
- isEditing: false,
- widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
- }),
+ // 3. Reach outside this store and trigger the Django API call!
+ try {
+ await useAuthStore.getState().saveDashboardLayout(currentLayout);
+ } catch (error) {
+ console.error("Failed to sync layout to MariaDB:", error);
+ // If the database save fails, you could technically revert the UI here if needed
+ }
+ },
- saveEdit: async () => {
- // 1. Grab the exact current state of the grid
- const currentLayout = get().widgets;
+ updateWidgetSettings: (id, newSettings) => set((state) => {
+ // console.log("2. Zustand updating widget:", id, "with:", newSettings);
+ return {
+ widgets: state.widgets.map(widget =>
+ widget.id === id
+ ? {...widget, settings: {...widget.settings, ...newSettings}}
+ : widget
+ )
+ };
+ }),
- // 2. Lock the UI immediately so the user feels zero lag
- set({ isEditing: false, backupWidgets: [] });
+ addWidget: (widgetId, sizeKey) => {
+ const registryEntry = WIDGET_REGISTRY[widgetId];
- // 3. Reach outside this store and trigger the Django API call!
- try {
- await useAuthStore.getState().saveDashboardLayout(currentLayout);
- } catch (error) {
- console.error("Failed to sync layout to MariaDB:", error);
- // If the database save fails, you could technically revert the UI here if needed
- }
- },
+ if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
+ console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
+ return;
+ }
- updateWidgetSettings: (id, newSettings) => set((state) => {
- console.log("2. Zustand updating widget:", id, "with:", newSettings);
- return {
- widgets: state.widgets.map(widget =>
- widget.id === id
- ? { ...widget, settings: { ...widget.settings, ...newSettings } }
- : widget
- )
- };
- }),
+ const dimensions = registryEntry.allowedSizes[sizeKey];
+ const widgets = get().widgets;
- addWidget: (widgetId, sizeKey) => {
- const registryEntry = WIDGET_REGISTRY[widgetId];
+ let startX = 0;
+ let startY = Infinity;
- if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
- console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
- return;
- }
+ if (widgets.length > 0) {
+ const bottomRowY = Math.max(...widgets.map(w => w.y));
+ const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
+ const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
- const dimensions = registryEntry.allowedSizes[sizeKey];
- const widgets = get().widgets;
+ if (rightmostEdge + dimensions.w <= 12) {
+ startX = rightmostEdge; // ...tuck it right next to the last widget!
+ startY = bottomRowY; // ...and keep it on the same row.
+ }
+ }
+ const newWidget = {
+ id: uuid(),
+ type: widgetId,
+ size: sizeKey,
+ settings: registryEntry.defaultSettings,
+ x: startX,
+ y: startY,
+ w: dimensions.w,
+ h: dimensions.h
+ };
+ console.log("newWidget: ", newWidget);
+ set((state) => ({widgets: [...state.widgets, newWidget]}));
+ },
- let startX = 0;
- let startY = Infinity;
+ removeWidget: (id) => set((state) => ({
+ widgets: state.widgets.filter(w => w.id !== id)
+ })),
- if (widgets.length > 0) {
- const bottomRowY = Math.max(...widgets.map(w => w.y));
- const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
- const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
+ updateLayoutPositions: (rglLayout) => {
+ set((state) => ({
+ widgets: state.widgets.map(widget => {
+ const gridItem = rglLayout.find(l => l.i === widget.id);
+ return gridItem ? {...widget, x: gridItem.x, y: gridItem.y} : widget;
+ })
+ }));
+ },
- if (rightmostEdge + dimensions.w <= 12) {
- startX = rightmostEdge; // ...tuck it right next to the last widget!
- startY = bottomRowY; // ...and keep it on the same row.
- }
- }
- const newWidget = {
- id: uuid(),
- type: widgetId,
- size: sizeKey,
- x: startX,
- y: startY,
- w: dimensions.w,
- h: dimensions.h
- };
-
- set((state) => ({ widgets: [...state.widgets, newWidget] }));
- },
-
- removeWidget: (id) => set((state) => ({
- widgets: state.widgets.filter(w => w.id !== id)
- })),
-
- updateLayoutPositions: (rglLayout) => {
- set((state) => ({
- widgets: state.widgets.map(widget => {
- const gridItem = rglLayout.find(l => l.i === widget.id);
- return gridItem ? { ...widget, x: gridItem.x, y: gridItem.y } : widget;
- })
- }));
- },
-
- // NEW: Action to hydrate the store on initial page load
- setWidgets: (savedWidgets) => set({ widgets: savedWidgets || [] })
+ // NEW: Action to hydrate the store on initial page load
+ setWidgets: (savedWidgets) => set({widgets: savedWidgets || []})
}));
\ No newline at end of file