From bb1e55d79609253b79ddd45dcff62115674d7370 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Wed, 22 Jul 2026 16:29:17 -0700 Subject: [PATCH 01/24] added a basic ivans widget, still needs ui refactoring --- client/src/components/Widgets/IvansAlerts.jsx | 164 ++++++++++++++++++ .../Widgets/IvansAlerts.stories.jsx | 122 +++++++++++++ client/src/constants/widgetRegistry.jsx | 1 + 3 files changed, 287 insertions(+) create mode 100644 client/src/components/Widgets/IvansAlerts.jsx create mode 100644 client/src/components/Widgets/IvansAlerts.stories.jsx diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx new file mode 100644 index 0000000..f9d35ef --- /dev/null +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -0,0 +1,164 @@ +import VdapApi from '../../hooks/useVdapApi.js'; +import {Flex, Spinner, Text, Button, Dialog, Box} from "@chakra-ui/react"; +import * as url from "node:url"; // Adjust path as needed +import { useState } from 'react'; + +export default function IvansAlerts() { + console.log("IvansAlerts"); + const [isSummaryOpen, setIsSummaryOpen] = useState(false); + const [isFileOpen, setIsFileOpen] = useState(false); + + function SummaryDialog({ open, onOpenChange, item }) { + return ( + + + + + + Summary + + + + {item.summary} + + + + + + + + + + ) + } + + function FileDialog({ open, onOpenChange, item, context }) { + return ( + + + + + + + + {item.name ? item.name : 'PDF Attachment'} + {context} + + + {item.type} + + + + + + + + + Your browser cannot display this PDF directly. + + Download PDF + + + + + + + + + + + + + + ) + } + + const get_7_day_range_url = () => { + const base_url = '/api/v2/comm_coor_ivans' + let today = new Date(); + let sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA'); + today = today.toLocaleDateString('en-CA'); + + const url_with_dates = base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; + return url_with_dates; + } + + const seven_day_url = get_7_day_range_url(); + + const {data: apiData, isLoading, error} = VdapApi(seven_day_url); + + if (isLoading) return ( + + + + ); + + if (error) return ( + + {error} + + ); + + if (apiData.length === 0) return ( + + No flight level data available. + + ); + + return ( + + + Volcano + Report Number + Report Send Time + Preparer + Summary + Attachments + + {apiData.map((item, index) => { + const context_str = item.volcano_name + " " + "•" + " " + item.send_time; + + return ( + + {item.volcano_name} + {item.report_num} + {item.send_time} + {item.preparer} + + setIsSummaryOpen(e.open)} + item={item} + /> + {item.files.map((item, index) => { + const $file_name = item.name; + const $url = item.url; + const $type = item.type; + + if ($type === "pdf") return ( + <> + + setIsFileOpen(e.open)} + item={item} + context={context_str} + /> + + ) + })} + + ) + })} + + + + ); +} \ 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..f528b51 --- /dev/null +++ b/client/src/components/Widgets/IvansAlerts.stories.jsx @@ -0,0 +1,122 @@ +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" + } + ] + } +]; + +// 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/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index 5b547bd..77cb4ea 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -41,6 +41,7 @@ export const WIDGET_REGISTRY = { name: "IVANS", category: "Communications", description: "Feed of recent IVANS reports and reviewer statuses.", + component: lazy(() => import('../components/widgets/IvansAlerts.jsx')), allowedSizes: { small: { w: 3, h: 12 }, medium: { w: 6, h: 24 } From 8525dcc71c5f4c4cc72dd7755d8470cc8a79d48b Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Thu, 30 Jul 2026 09:18:47 -0700 Subject: [PATCH 02/24] adding working IVANS widget --- .../components/Widgets/ExplosionEvents.jsx | 2 +- client/src/components/Widgets/IvansAlerts.jsx | 504 ++++++++++++------ .../Widgets/IvansAlerts.stories.jsx | 5 + .../components/Widgets/VaaActivity7Day.jsx | 2 +- .../Widgets/settings/IvansAlertsSettings.jsx | 46 ++ client/src/constants/widgetRegistry.jsx | 16 +- client/src/pages/home/tabs/MyDashboard.jsx | 365 ++++++------- client/src/store/useDashboardStore.jsx | 179 +++---- 8 files changed, 684 insertions(+), 435 deletions(-) create mode 100644 client/src/components/Widgets/settings/IvansAlertsSettings.jsx diff --git a/client/src/components/Widgets/ExplosionEvents.jsx b/client/src/components/Widgets/ExplosionEvents.jsx index ddd8044..8aad3b7 100644 --- a/client/src/components/Widgets/ExplosionEvents.jsx +++ b/client/src/components/Widgets/ExplosionEvents.jsx @@ -4,7 +4,7 @@ import { useColorMode } from "@/components/ui/color-mode.jsx"; import Plot from 'react-plotly.js'; import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed -export default function ExplosionEvents({ settings }) { +export default function ExplosionEvents({ settings, widgetID }) { // Plotly expects 'linear' for normal axes const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear'; diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index f9d35ef..345da11 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -1,164 +1,354 @@ import VdapApi from '../../hooks/useVdapApi.js'; -import {Flex, Spinner, Text, Button, Dialog, Box} from "@chakra-ui/react"; -import * as url from "node:url"; // Adjust path as needed -import { useState } from 'react'; +import {Button, Dialog, Flex, Grid, GridItem, Spinner, Text} from "@chakra-ui/react"; +import {useEffect, useMemo, useState} from 'react'; +import {useDashboardStore} from "@/store/useDashboardStore.jsx"; -export default function IvansAlerts() { - console.log("IvansAlerts"); - const [isSummaryOpen, setIsSummaryOpen] = useState(false); - const [isFileOpen, setIsFileOpen] = useState(false); +export default function IvansAlerts({ settings, widgetID }) { + console.log("IvansAlerts"); + const [activeSummaryItem, setActiveSummaryItem] = useState(false); + const [activeFileItem, setActiveFileItem] = useState(false); + const [activeImageItem, setActiveImageItem] = useState(false); + const widgets = useDashboardStore(state => state.widgets); + console.log("widgets: ", widgets); + const updateSettings = useDashboardStore(state => state.updateWidgetSettings); + const isCopy = settings?.copyToClipboard === true; // acts as a failsafe if no default setting is set + const sorts = settings?.activeSorts; - function SummaryDialog({ open, onOpenChange, item }) { + // build url once on mount + const seven_day_url = useMemo(() => { + const base_url = '/api/v2/comm_coor_ivans' + 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; + }, []); + + // VdapApi hook only calls api if the url changes + const {data: apiD, isLoading, error} = VdapApi(seven_day_url); + + const apiData = [ + { + 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: 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: 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" + } + ] + } + ]; + + const data = useMemo(() => { + if (apiData.length === 0) return []; + let sorted = []; + + if (sorts?.volcanoSort && sorts?.timeSort === "asc") { + sorted = [...apiData].sort(ascTimeVolcanoSort); + } else if (sorts?.volcanoSort && sorts?.timeSort === "desc") { + sorted = [...apiData].sort(descTimeVolcanoSort); + } else if (sorts?.timeSort === "asc") { + sorted = [...apiData].sort(ascTimeSort); + } else { + sorted = [...apiData].sort(descTimeSort); + } + + console.log("sorted data: ", sorted); + return sorted; + }, [apiData, sorts]); + + useEffect(() => { + if(isCopy) { + navigator.clipboard.writeText(data) + .then(() => { + alert('Copied to clipboard!'); + }) + .catch((err => { + console.error('Failed to copy: ', err); + })); + updateSettings(widgetID, {copyToClipboard: false}); + } + }, [isCopy]); + + if (isLoading) return ( + + + + ); + + if (error) return ( + + {error} + + ); + + if (data.length === 0) return ( + + No flight level data available this week. + + ); + + return ( + + {/**/} + {/* Volcano*/} + {/* Report Number*/} + {/* Report Send Time*/} + {/* Preparer*/} + {/* Summary*/} + {/* Attachments*/} + {/**/} + {data.map((item, index) => { + const context_string = item.volcano_name + ' • ' + item.send_time; return ( - - - - - - Summary - - - - {item.summary} - - - - - - - - - - ) - } - - function FileDialog({ open, onOpenChange, item, context }) { - return ( - - - - - - - - {item.name ? item.name : 'PDF Attachment'} - {context} - - - {item.type} - - - - - - - - - Your browser cannot display this PDF directly. - - Download PDF - - - - - - - - - - - - - - ) - } - - const get_7_day_range_url = () => { - const base_url = '/api/v2/comm_coor_ivans' - let today = new Date(); - let sevenDaysAgo = new Date(today); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA'); - today = today.toLocaleDateString('en-CA'); - - const url_with_dates = base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; - return url_with_dates; - } - - const seven_day_url = get_7_day_range_url(); - - const {data: apiData, isLoading, error} = VdapApi(seven_day_url); - - if (isLoading) return ( - - - - ); - - if (error) return ( - - {error} - - ); - - if (apiData.length === 0) return ( - - No flight level data available. - - ); - - return ( - - - Volcano - Report Number - Report Send Time - Preparer - Summary - Attachments - - {apiData.map((item, index) => { - const context_str = item.volcano_name + " " + "•" + " " + item.send_time; - - return ( - - {item.volcano_name} - {item.report_num} - {item.send_time} - {item.preparer} - - setIsSummaryOpen(e.open)} - item={item} - /> - {item.files.map((item, index) => { - const $file_name = item.name; - const $url = item.url; - const $type = item.type; - - if ($type === "pdf") return ( - <> - - setIsFileOpen(e.open)} - item={item} - context={context_str} - /> - - ) - })} - + + {item.volcano_name} + {item.report_num} + {item.send_time} + {item.preparer} + + <> + + setActiveSummaryItem(null)} + item={activeSummaryItem} + context={context_string} + /> + + + + + {item.files.length === 0 ? ( + No Attachments + ):( + item.files.map((file, index) => { + if (file.type === "pdf") return ( +
+ + setActiveFileItem(null)} + item={activeFileItem} + context={context_string} + /> +
) - })} - + else if (file.type === "image") return ( +
+ + setActiveImageItem(null)} + item={activeImageItem} + context={context_string} + /> +
+ ) + else if (file.type === "download") return ( + + ) + }) + )} +
+
+
+ ) + })} +
+ ); +} + +function SummaryDialog({ open, onOpenChange, item }) { + return ( + + + + + + + + + {item?.volcano_name} + Preparer: {item?.preparer} + + {item?.send_time} + + + + + + {item?.summary} + + + + + + + + + + ) +} + +function FileDialog({ open, onOpenChange, item, context }) { + return ( + + + + + + + + {item?.name ? item?.name : 'PDF Attachment'} + {context} + + {(item?.type || '').toUpperCase()} -
- ); + + + + + + + Your browser cannot display this PDF directly. + + Download PDF + + + + + + + + + + + + + + ) +} + +function ImageDialog({ open, onOpenChange, item, context }) { + return ( + + + + + + {item?.name} + {context} + + + + {item?.name} + + + + + + + + + + ) +} + +const downloadReport = ( url, name ) => { + const link = document.createElement('a'); + link.href = url; + link.download = name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +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 index f528b51..a79dfcd 100644 --- a/client/src/components/Widgets/IvansAlerts.stories.jsx +++ b/client/src/components/Widgets/IvansAlerts.stories.jsx @@ -67,6 +67,11 @@ const mockData = [ 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", 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/settings/IvansAlertsSettings.jsx b/client/src/components/Widgets/settings/IvansAlertsSettings.jsx new file mode 100644 index 0000000..59a8d6e --- /dev/null +++ b/client/src/components/Widgets/settings/IvansAlertsSettings.jsx @@ -0,0 +1,46 @@ +import {Button, Flex, Switch, Text} from "@chakra-ui/react"; +import {useDashboardStore} from "../../../store/useDashboardStore.jsx"; + +export default function IvansAlertsSettings({ widgetId, currentSettings }) { + const updateSettings = useDashboardStore(state => state.updateWidgetSettings); + + 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 ( + + + + + + + + Sort Oldest to Newest + + + + + + + Sort by Volcano + + + ) +} \ No newline at end of file diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index 77cb4ea..7eb9bd4 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -41,10 +41,17 @@ export const WIDGET_REGISTRY = { name: "IVANS", category: "Communications", description: "Feed of recent IVANS reports and reviewer statuses.", - component: lazy(() => import('../components/widgets/IvansAlerts.jsx')), + component: lazy(() => import('../components/Widgets/IvansAlerts.jsx')), + settingsComponent: lazy(() => import('../components/Widgets/settings/IvansAlertsSettings.jsx')), + defaultSettings: { + copyToClipboard: false, + activeSorts: { + timeSort: "desc", + volcanoSort: false + } + }, allowedSizes: { - small: { w: 3, h: 12 }, - medium: { w: 6, h: 24 } + small: { w: 6, h: 9 } } }, @@ -89,8 +96,7 @@ 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 } diff --git a/client/src/pages/home/tabs/MyDashboard.jsx b/client/src/pages/home/tabs/MyDashboard.jsx index 05f2c24..86452a7 100644 --- a/client/src/pages/home/tabs/MyDashboard.jsx +++ b/client/src/pages/home/tabs/MyDashboard.jsx @@ -1,209 +1,210 @@ -import { Box, Text, Flex, IconButton, Spinner, Popover } from "@chakra-ui/react"; -import { X, MoreVertical } from "lucide-react"; -import { Responsive, WidthProvider } from "react-grid-layout"; -import { useDashboardStore } from "@/store/useDashboardStore.jsx"; -import { Suspense, useEffect, useRef } from 'react'; -import { useColorModeValue } from "@/components/ui/color-mode"; +import {Box, Text, Flex, IconButton, Spinner, Popover} from "@chakra-ui/react"; +import {X, MoreVertical} from "lucide-react"; +import {Responsive, WidthProvider} from "react-grid-layout"; +import {useDashboardStore} from "@/store/useDashboardStore.jsx"; +import {Suspense, useEffect, useRef} from 'react'; +import {useColorModeValue} from "@/components/ui/color-mode"; // 1. Import database messenger import useAuthStore from "@/store/authStore"; // 2. Import registry -import { WIDGET_REGISTRY } from '@/constants/widgetRegistry.jsx'; +import {WIDGET_REGISTRY} from '@/constants/widgetRegistry.jsx'; // React-Grid-Layout requires this wrapper to automatically calculate screen width const ResponsiveGridLayout = WidthProvider(Responsive); export default function MyDashboard() { - // 🌗 Let's adjust for our light and dark mode - const frameBg = useColorModeValue("gray.100", "gray.800"); - const titleColor = useColorModeValue("gray.800", "gray.100"); - const iconColor = useColorModeValue("gray.600", "gray.400"); - const iconHoverBg = useColorModeValue("gray.100", "gray.700"); + // 🌗 Let's adjust for our light and dark mode + const frameBg = useColorModeValue("gray.100", "gray.800"); + const titleColor = useColorModeValue("gray.800", "gray.100"); + const iconColor = useColorModeValue("gray.600", "gray.400"); + const iconHoverBg = useColorModeValue("gray.100", "gray.700"); - // ☁️ Database Connection - const { user } = useAuthStore(); + // ☁️ Database Connection + const {user} = useAuthStore(); - // ☁️ Local UI Connections - const widgets = useDashboardStore(state => state.widgets); - const isEditing = useDashboardStore(state => state.isEditing); - const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions); - const removeWidget = useDashboardStore(state => state.removeWidget); - const setWidgets = useDashboardStore(state => state.setWidgets); // The injection action + // ☁️ Local UI Connections + const widgets = useDashboardStore(state => state.widgets); + const isEditing = useDashboardStore(state => state.isEditing); + const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions); + const removeWidget = useDashboardStore(state => state.removeWidget); + const setWidgets = useDashboardStore(state => state.setWidgets); // The injection action - // 🔒 Safety lock to prevent infinite re-renders - const hasHydrated = useRef(false); + // 🔒 Safety lock to prevent infinite re-renders + const hasHydrated = useRef(false); - // ========================================== - // INITIAL LOAD: Grab data from Django - // ========================================== - useEffect(() => { - if (user?.preferences?.dashboard_layout && !hasHydrated.current) { - const savedLayout = user.preferences.dashboard_layout; + // ========================================== + // INITIAL LOAD: Grab data from Django + // ========================================== + useEffect(() => { + if (user?.preferences?.dashboard_layout && !hasHydrated.current) { + const savedLayout = user.preferences.dashboard_layout; - // Safety check: Only inject if it's actually an array of widgets. - // (Prevents a crash if MariaDB returns a default empty object {}) - if (Array.isArray(savedLayout)) { - setWidgets(savedLayout); - } + // Safety check: Only inject if it's actually an array of widgets. + // (Prevents a crash if MariaDB returns a default empty object {}) + if (Array.isArray(savedLayout)) { + setWidgets(savedLayout); + } - hasHydrated.current = true; - } - }, [user, setWidgets]); + hasHydrated.current = true; + } + }, [user, setWidgets]); - // 🛠️ Format the data for RGL - const rglLayout = widgets.map(w => ({ - i: w.id, - x: w.x, - y: w.y, - w: w.w, - h: w.h, - isResizable: false // 🚫 The magic lock! No resizing allowed globally. - })); + // 🛠️ Format the data for RGL + const rglLayout = widgets.map(w => ({ + i: w.id, + x: w.x, + y: w.y, + w: w.w, + h: w.h, + isResizable: false // 🚫 The magic lock! No resizing allowed globally. + })); - // 3. THE COMPONENT MAPPER - const renderWidget = (widget) => { - const registryEntry = WIDGET_REGISTRY[widget.type]; + // 3. THE COMPONENT MAPPER + const renderWidget = (widget) => { + const registryEntry = WIDGET_REGISTRY[widget.type]; - if (registryEntry && registryEntry.component) { - const Component = registryEntry.component; + if (registryEntry && registryEntry.component) { + const Component = registryEntry.component; - // 1. Check if this widget type has a settings component registered - const SettingsUI = registryEntry.settingsComponent; + // 1. Check if this widget type has a settings component registered + const SettingsUI = registryEntry.settingsComponent; - return ( - // The "Frame" that holds both the Title and the Chart - + return ( + // The "Frame" that holds both the Title and the Chart + - {/* THE TITLE BAR */} - - - {registryEntry.name} - + {/* THE TITLE BAR */} + + + {registryEntry.name} + - {/* THE SETTINGS ELLIPSIS */} - {SettingsUI ? ( - - - - - - + {/* THE SETTINGS ELLIPSIS */} + {SettingsUI ? ( + + + + + + - {/* NEW: The Positioner lifts the content out of the Flexbox flow */} - - - }> - - - - - - ) : ( - - )} - - - {/* THE CHART CANVAS */} - - {/* Pass the settings object into the actual chart component */} - - - - - ); - } - - // Fallback if someone asks for a widget that doesn't exist - return ( - - Error - Widget '{widget.type}' not found. - - ); - }; - - return ( - - updateLayoutPositions(newLayout)} // Fires when user drops a widget - compactType="vertical" // Auto-packs widgets to the top - > - {/* 3. 🎨 Paint the Widgets */} - {widgets.map(widget => ( - - {/* 4. THE DELETE BUTTON */} - {isEditing && ( - removeWidget(widget.id)} - onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget - onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches - > - - - )} - - {/* 4. SUSPENSE BOUNDARY wrapping the render function */} - - - - }> - {renderWidget(widget)} - - - - ))} - - - {/* Empty State Helper */} - {widgets.length === 0 && ( - - Your dashboard is empty. Click Settings to add widgets. - + {/* NEW: The Positioner lifts the content out of the Flexbox flow */} + + + }> + + + + + + ) : ( + )} - + + + {/* THE CHART CANVAS */} + + {/* Pass the settings object into the actual chart component */} + {/* Pass widget ID to allow updating settings in widget component rather than just in settings component */} + + + + + ); + } + + // Fallback if someone asks for a widget that doesn't exist + return ( + + Error + Widget '{widget.type}' not found. + ); + }; + + return ( + + updateLayoutPositions(newLayout)} // Fires when user drops a widget + compactType="vertical" // Auto-packs widgets to the top + > + {/* 3. 🎨 Paint the Widgets */} + {widgets.map(widget => ( + + {/* 4. THE DELETE BUTTON */} + {isEditing && ( + removeWidget(widget.id)} + onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget + onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches + > + + + )} + + {/* 4. SUSPENSE BOUNDARY wrapping the render function */} + + + + }> + {renderWidget(widget)} + + + + ))} + + + {/* Empty State Helper */} + {widgets.length === 0 && ( + + Your dashboard is empty. Click Settings to add widgets. + + )} + + ); } \ 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 From 999e3bb50cd4a5e632c0a00f03583ecd18c7d4f9 Mon Sep 17 00:00:00 2001 From: "Kuchenik, Venessa J" Date: Wed, 5 Aug 2026 17:16:45 +0000 Subject: [PATCH 03/24] Adding functioning plot of Plume Events widget --- .DS_Store | Bin 6148 -> 6148 bytes client/eslint.config.js | 2 +- client/src/components/Widgets/IvansAlerts.jsx | 365 +++++++----------- client/src/components/Widgets/PlumeEvents.jsx | 138 +++++++ client/src/constants/widgetRegistry.jsx | 17 +- client/src/pages/home/tabs/MyDashboard.jsx | 2 +- 6 files changed, 292 insertions(+), 232 deletions(-) create mode 100644 client/src/components/Widgets/PlumeEvents.jsx diff --git a/.DS_Store b/.DS_Store index b790d6a6e032f588929beb92a9b92cf0bdeac837..63386c8b95e79308c126a4ff0cb95d29d73f15f4 100644 GIT binary patch delta 657 zcmY*Xzi-n(82vswG>VLNu^7PFX>v35w2n#?ZH zXQb$nr=CxAW#(vKk2h|R%A;{@bGONxV&lT0*}6lBeZS_=O0HUUiB~+rOS%$qO@~=l zp1IVc%q~4}>#UviNF!@k8INpH(#|#gBX^59F2_LA8;gpivg4HP8p-TFw8{pta(0G! z9&7lmz8xPW?-1YWFuc$#o7AqUtH1aa@};KpBXVI#6i|l*K#Gl6fT$i+4*+_6yXlW5 z%WC>=a3;Y5tic^9Kpoof5?;ew_yC{b8~lLZ7{Uae#|2E`On8kZ&VjU?y9_j@Q se9i_K0J@y~_tvdKe@^^62ul%(f4ISA@sHS##MUHkAo!v|G4@XU2QpQslmGw# delta 148 zcmZoMXfc=|#>B)qu~2NHo+2aj#(>?7jLegHSlq>SCKcpl7MBhw);O79^xml3oJM(0I5l0RNAYf!*VA&iYvW6J|g0Ulf diff --git a/client/eslint.config.js b/client/eslint.config.js index 8c2c3fe..2eefe1f 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -24,6 +24,6 @@ export default defineConfig([globalIgnores(['dist']), { }, }, rules: { - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'no-unused-vars': 'off', }, }, ...storybook.configs["flat/recommended"]]) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index 345da11..7485339 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -1,22 +1,18 @@ import VdapApi from '../../hooks/useVdapApi.js'; -import {Button, Dialog, Flex, Grid, GridItem, Spinner, Text} from "@chakra-ui/react"; -import {useEffect, useMemo, useState} from 'react'; +import {Button, Dialog, Flex, Portal, Spinner, Text} from "@chakra-ui/react"; +import {useEffect, useMemo, useState } from 'react'; import {useDashboardStore} from "@/store/useDashboardStore.jsx"; export default function IvansAlerts({ settings, widgetID }) { - console.log("IvansAlerts"); + console.log("IvansAlerts widget ID: ", widgetID); const [activeSummaryItem, setActiveSummaryItem] = useState(false); - const [activeFileItem, setActiveFileItem] = useState(false); - const [activeImageItem, setActiveImageItem] = useState(false); - const widgets = useDashboardStore(state => state.widgets); - console.log("widgets: ", widgets); const updateSettings = useDashboardStore(state => state.updateWidgetSettings); - const isCopy = settings?.copyToClipboard === true; // acts as a failsafe if no default setting is set + const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set const sorts = settings?.activeSorts; // build url once on mount const seven_day_url = useMemo(() => { - const base_url = '/api/v2/comm_coor_ivans' + const base_url = '/api/v2/comm_coor_ivans'; let today = new Date(); let sevenDaysAgo = new Date(today); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); @@ -27,74 +23,76 @@ export default function IvansAlerts({ settings, widgetID }) { return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; }, []); - // VdapApi hook only calls api if the url changes + // VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data const {data: apiD, isLoading, error} = VdapApi(seven_day_url); - const apiData = [ - { - 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: 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: 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" - } - ] - } - ]; + const apiData = useMemo(() => { + return ([ + { + 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: 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: 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" + } + ] + } + ]) + }, []); const data = useMemo(() => { - if (apiData.length === 0) return []; - let sorted = []; + if (apiData?.length === 0) return []; + let sorted; if (sorts?.volcanoSort && sorts?.timeSort === "asc") { sorted = [...apiData].sort(ascTimeVolcanoSort); @@ -106,13 +104,13 @@ export default function IvansAlerts({ settings, widgetID }) { sorted = [...apiData].sort(descTimeSort); } - console.log("sorted data: ", sorted); return sorted; }, [apiData, sorts]); useEffect(() => { if(isCopy) { - navigator.clipboard.writeText(data) + const dataText = JSON.stringify(data); // TODO: this is pretty ugly when copied and pasted, format it better + navigator.clipboard.writeText(dataText) .then(() => { alert('Copied to clipboard!'); }) @@ -121,7 +119,7 @@ export default function IvansAlerts({ settings, widgetID }) { })); updateSettings(widgetID, {copyToClipboard: false}); } - }, [isCopy]); + }, [isCopy, data, widgetID, updateSettings]); if (isLoading) return ( @@ -142,187 +140,96 @@ export default function IvansAlerts({ settings, widgetID }) { ); return ( - - {/**/} - {/* Volcano*/} - {/* Report Number*/} - {/* Report Send Time*/} - {/* Preparer*/} - {/* Summary*/} - {/* Attachments*/} - {/**/} + {data.map((item, index) => { - const context_string = item.volcano_name + ' • ' + item.send_time; return ( - - {item.volcano_name} - {item.report_num} - {item.send_time} - {item.preparer} - - <> - - setActiveSummaryItem(null)} - item={activeSummaryItem} - context={context_string} - /> - - - - - {item.files.length === 0 ? ( - No Attachments - ):( - item.files.map((file, index) => { - if (file.type === "pdf") return ( -
- - setActiveFileItem(null)} - item={activeFileItem} - context={context_string} - /> -
- ) - else if (file.type === "image") return ( -
- - setActiveImageItem(null)} - item={activeImageItem} - context={context_string} - /> -
- ) - else if (file.type === "download") return ( - - ) - }) - )} -
-
-
+ /* IVANS ENTRIES */ + setActiveSummaryItem(item)} w="100%" h="100%" key={index} borderRadius="lg" bg="gray.700" justify="space-between" align="center"> + + {item.volcano_name} + {item.send_time} + + + {item.report_num} + + ) })} + {/* SUMMARY/PDF VIEWER */} + setActiveSummaryItem(null)} + item={activeSummaryItem} + />
); } function SummaryDialog({ open, onOpenChange, item }) { return ( - - - - - + + + + - - - {item?.volcano_name} - Preparer: {item?.preparer} + + + + {item?.volcano_name} + + + {item?.send_time} + + + + + prepared by: {item?.preparer} + - {item?.send_time} - {item?.summary} + {item?.summary} +
+ {item?.files?.map((file, index) => { + return ( + + + + Your browser cannot display this PDF directly. + + + Download PDF + + + + )})}
- - -
) } -function FileDialog({ open, onOpenChange, item, context }) { - return ( - - - - - - - - {item?.name ? item?.name : 'PDF Attachment'} - {context} - - {(item?.type || '').toUpperCase()} - - - - - - - - Your browser cannot display this PDF directly. - - Download PDF - - - - - - - - - - - - - - ) -} - -function ImageDialog({ open, onOpenChange, item, context }) { - return ( - - - - - - {item?.name} - {context} - - - - {item?.name} - - - - - - - - - - ) -} - -const downloadReport = ( url, name ) => { - const link = document.createElement('a'); - link.href = url; - link.download = name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -} - function ascTimeVolcanoSort(a, b) { if (a.volcano_name < b.volcano_name) return -1 else if (a.volcano_name > b.volcano_name) return 1 diff --git a/client/src/components/Widgets/PlumeEvents.jsx b/client/src/components/Widgets/PlumeEvents.jsx new file mode 100644 index 0000000..8470814 --- /dev/null +++ b/client/src/components/Widgets/PlumeEvents.jsx @@ -0,0 +1,138 @@ +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"; + +export default function PlumeEvents({ settings, widgetID}) { + const { data: apiData, isLoading, error } = VdapApi('/api/v2/plume_events?start_date=2026-01-01&end_date=2026-03-01'); + + 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/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index 7eb9bd4..0ae2a2c 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -51,7 +51,11 @@ export const WIDGET_REGISTRY = { } }, allowedSizes: { - small: { w: 6, h: 9 } + small: { w: 3, h: 6 }, + medium: { w: 3, h: 9 }, + large: { w: 3, h: 12 }, + gargantuan: { w: 6, h: 12 }, + } }, @@ -93,6 +97,17 @@ export const WIDGET_REGISTRY = { } }, + "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')), + defaultSettings: {}, + allowedSizes: { + medium: { w: 6, h: 12 } + } + }, + "llm_al_changes_quad_plot": { name: "Alert Level Changes Quad Plot", category: "Computer Science", diff --git a/client/src/pages/home/tabs/MyDashboard.jsx b/client/src/pages/home/tabs/MyDashboard.jsx index 86452a7..1c1b846 100644 --- a/client/src/pages/home/tabs/MyDashboard.jsx +++ b/client/src/pages/home/tabs/MyDashboard.jsx @@ -122,7 +122,7 @@ export default function MyDashboard() { {/* Pass the settings object into the actual chart component */} {/* Pass widget ID to allow updating settings in widget component rather than just in settings component */} - +
From 0861ca2b94b75db6bc3c12b39e2eaec799210c4d Mon Sep 17 00:00:00 2001 From: "Kuchenik, Venessa J" Date: Wed, 5 Aug 2026 18:58:47 +0000 Subject: [PATCH 04/24] Adding functional IVANS widget with dialog popup view of summary and pdfs --- client/src/components/Widgets/IvansAlerts.jsx | 29 ++++++++++--------- client/src/constants/widgetRegistry.jsx | 1 - 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index 7485339..d669a72 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -203,19 +203,22 @@ function SummaryDialog({ open, onOpenChange, item }) {
{item?.files?.map((file, index) => { return ( - - - - Your browser cannot display this PDF directly. - - - Download PDF - - - + <> + + + + Your browser cannot display this PDF directly. + + + Download PDF + + + +
+ )})} diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index 0ae2a2c..abb0d68 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -55,7 +55,6 @@ export const WIDGET_REGISTRY = { medium: { w: 3, h: 9 }, large: { w: 3, h: 12 }, gargantuan: { w: 6, h: 12 }, - } }, From dccebbc21bc587d429ce7a5e1661c52a19a97c51 Mon Sep 17 00:00:00 2001 From: "Kuchenik, Venessa J" Date: Thu, 6 Aug 2026 14:58:12 +0000 Subject: [PATCH 05/24] Update viewing format of ivans summary and pdf --- client/src/components/Widgets/IvansAlerts.jsx | 156 ++++++++---------- client/src/constants/widgetRegistry.jsx | 3 +- 2 files changed, 67 insertions(+), 92 deletions(-) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index d669a72..1464711 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -1,11 +1,10 @@ -import VdapApi from '../../hooks/useVdapApi.js'; -import {Button, Dialog, Flex, Portal, Spinner, Text} from "@chakra-ui/react"; -import {useEffect, useMemo, useState } from 'react'; +import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react"; +import {useEffect, useMemo } from 'react'; import {useDashboardStore} from "@/store/useDashboardStore.jsx"; +import VdapApi from '../../hooks/useVdapApi.js'; export default function IvansAlerts({ settings, widgetID }) { console.log("IvansAlerts widget ID: ", widgetID); - const [activeSummaryItem, setActiveSummaryItem] = useState(false); const updateSettings = useDashboardStore(state => state.updateWidgetSettings); const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set const sorts = settings?.activeSorts; @@ -122,13 +121,13 @@ export default function IvansAlerts({ settings, widgetID }) { }, [isCopy, data, widgetID, updateSettings]); if (isLoading) return ( - + ); if (error) return ( - + {error} ); @@ -140,98 +139,75 @@ export default function IvansAlerts({ settings, widgetID }) { ); return ( - + {data.map((item, index) => { return ( - /* IVANS ENTRIES */ - setActiveSummaryItem(item)} w="100%" h="100%" key={index} borderRadius="lg" bg="gray.700" justify="space-between" align="center"> - - {item.volcano_name} - {item.send_time} - - - {item.report_num} - - + + + {(collapsible) => ( + <> + + + + {item.volcano_name} + {item.send_time} + + + {item.report_num} + + + + + + {item?.summary} +
+ {item?.files?.map((file, index) => { + return ( + <> + + + + Your browser cannot display this PDF directly. + + + Download PDF + + + +
+ + )})} +
+
+ + )} +
+
) })} - {/* SUMMARY/PDF VIEWER */} - setActiveSummaryItem(null)} - item={activeSummaryItem} - />
); } -function SummaryDialog({ open, onOpenChange, item }) { - return ( - - - - - - - - - {item?.volcano_name} - - - {item?.send_time} - - - - - prepared by: {item?.preparer} - - - - - - - - {item?.summary} -
- {item?.files?.map((file, index) => { - return ( - <> - - - - Your browser cannot display this PDF directly. - - - Download PDF - - - -
- - )})} -
- - - - -
-
-
- ) -} function ascTimeVolcanoSort(a, b) { if (a.volcano_name < b.volcano_name) return -1 diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index abb0d68..39b63f1 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -53,8 +53,7 @@ export const WIDGET_REGISTRY = { allowedSizes: { small: { w: 3, h: 6 }, medium: { w: 3, h: 9 }, - large: { w: 3, h: 12 }, - gargantuan: { w: 6, h: 12 }, + large: { w: 6, h: 9 }, } }, From 182495e11a246abc0e0a999e1adb1c38850c6356 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Mon, 10 Aug 2026 07:53:26 -0700 Subject: [PATCH 06/24] updated element sizing in ivans widget --- client/src/components/Widgets/IvansAlerts.jsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index 1464711..cf7e67c 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -144,13 +144,14 @@ export default function IvansAlerts({ settings, widgetID }) { pos="relative" justify="top" align="center" p={2} gap={2} - overflowY="auto" + overflow="auto" borderRadius="xl" bg="gray.800" > {data.map((item, index) => { return ( + {/* Using context to dynamically change the border radii of the triggers */} {(collapsible) => ( <> @@ -172,17 +173,18 @@ export default function IvansAlerts({ settings, widgetID }) {
- - - {item?.summary} + + {/* This box needs to be at h="100%" for the pdf toolbar to appear */} + + {item?.summary}
{item?.files?.map((file, index) => { return ( - <> - + @@ -194,7 +196,7 @@ export default function IvansAlerts({ settings, widgetID }) {
- + )})} From abb2c2ba5c6377001580e89f501269a70a24bdeb Mon Sep 17 00:00:00 2001 From: "Kuchenik, Venessa J" Date: Mon, 10 Aug 2026 15:02:37 +0000 Subject: [PATCH 07/24] Added plot of seismic events count with date picker feature in the settings --- .../src/components/Widgets/SeismicEvents.jsx | 155 ++++++++++++++++++ .../settings/SeismicEventsSettings.jsx | 64 ++++++++ client/src/constants/widgetRegistry.jsx | 14 +- 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Widgets/SeismicEvents.jsx create mode 100644 client/src/components/Widgets/settings/SeismicEventsSettings.jsx diff --git a/client/src/components/Widgets/SeismicEvents.jsx b/client/src/components/Widgets/SeismicEvents.jsx new file mode 100644 index 0000000..037ab48 --- /dev/null +++ b/client/src/components/Widgets/SeismicEvents.jsx @@ -0,0 +1,155 @@ +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"; + +export default function SeismicEvents({ settings, widgetID}) { + const url = useMemo(() =>{ + if (settings?.startDate && settings?.endDate) + return '/api/v2/seismic_events?start_date=' + settings?.startDate + '&end_date=' + settings?.endDate; + else { + const base_url = '/api/v2/seismic_events'; + 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; + } + }, [settings?.startDate, settings?.endDate]); + + console.log(url); + 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/settings/SeismicEventsSettings.jsx b/client/src/components/Widgets/settings/SeismicEventsSettings.jsx new file mode 100644 index 0000000..03bc0ba --- /dev/null +++ b/client/src/components/Widgets/settings/SeismicEventsSettings.jsx @@ -0,0 +1,64 @@ +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/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index 39b63f1..912c5f8 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -53,7 +53,7 @@ export const WIDGET_REGISTRY = { allowedSizes: { small: { w: 3, h: 6 }, medium: { w: 3, h: 9 }, - large: { w: 6, h: 9 }, + large: { w: 3, h: 12 }, } }, @@ -106,6 +106,18 @@ export const WIDGET_REGISTRY = { } }, + "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: {}, + allowedSizes: { + medium: { w: 6, h: 12 } + } + }, + "llm_al_changes_quad_plot": { name: "Alert Level Changes Quad Plot", category: "Computer Science", From a61d3f104e519778c2ef9bcaf3e0257a3197d2f1 Mon Sep 17 00:00:00 2001 From: "Kuchenik, Venessa J" Date: Mon, 10 Aug 2026 20:47:07 +0000 Subject: [PATCH 08/24] add rs widget with copy to clipboard and sorting capabilities --- .../src/components/Widgets/RemoteSensing.jsx | 279 ++++++++++++++++++ .../settings/RemoteSensingSettings.jsx | 46 +++ client/src/constants/widgetRegistry.jsx | 13 +- 3 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Widgets/RemoteSensing.jsx create mode 100644 client/src/components/Widgets/settings/RemoteSensingSettings.jsx diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx new file mode 100644 index 0000000..60cf297 --- /dev/null +++ b/client/src/components/Widgets/RemoteSensing.jsx @@ -0,0 +1,279 @@ +import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react"; +import {useEffect, useMemo } from 'react'; +import {useDashboardStore} from "@/store/useDashboardStore.jsx"; +import VdapApi from '../../hooks/useVdapApi.js'; + +export default function RemoteSensing({ settings, widgetID }) { + console.log("RS widget ID: ", widgetID); + const updateSettings = useDashboardStore(state => state.updateWidgetSettings); + const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set + const sorts = settings?.activeSorts; + + // build url once on mount + const seven_day_url = useMemo(() => { + const base_url = '/api/v2/remote_sensing'; + 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; + }, []); + + // VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data + const {data: apiD, isLoading, error} = VdapApi(seven_day_url); + + const apiData = useMemo(() => { + return ([ + { + id: 1848, + volcano_name: "Lewotolok", + country: "Indonesia", + report_time: "2026-01-13T20:39:39Z", + summary: "Dear friends,\n\nOur latest satellite view from 13 January (~0900 local time) shows that the lava has began to flow over the crater rim onto the northwest flank (see attached). The lava flow spreads into three lobes from the single source vent on the northwest flank of the active cone. The longest portion of the flow was about 530 meters from the vent with the toe draping about 30-40 meters over the rim of the main crater just north of the May 2024 lava flow. A narrow deposit of volcanoclastic debris extends about 300 meters below the toe of the lava flow (white dashed outline on sketch).\n\nPlease let us know if you have any questions.\nBest wishes,\nRick", + sender: "rwessels@usgs.gov", + files: [ + { + name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", + url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", + type: "image" + }, + { + name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", + url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", + type: "image" + } + ] + }, + { + id: 1847, + volcano_name: "Mayon", + country: "Philippines", + report_time: "2026-01-13T17:56:46Z", + summary: "Dear PHIVOLCS friends,\nOur most recent satellite data from 13 January (1500 local) show the new lava flow extending southeast from the summit and splitting into two lobes (see attached pdf). The total length of the longest lobe was about 1 kilometer. Compared to data from 12 January, the longest part of the flow advanced almost 200 meters over 11 hours while the shorter section advanced about 80 meters over the same time. Debris falling from the front of both lava flow lobes extend another several hundred meters below each lobe. We continue to watch the activity at Mayon volcano and will share anything that might help. \n\nPlease let us know if you have any questions, requests, or comments.\n\nBest wishes,\nRick", + sender: "rwessels@usgs.gov", + files: [ + { + name: "Mayon-RP-images-20260113_ICEYE_lava.pdf", + url: "https://vdap.org/commcoordashboard/rs/attachments/1847-Mayon-RP-images-20260113_ICEYE_lava.pdf", + type: "pdf" + } + ] + }, + { + id: 1889, + volcano_name: "Bagana", + country: "Papua New Guinea", + report_time: "2026-06-24T21:28:58Z", + summary: "Hi Steve and RVO,\nI'm now back in the office after some time away and have looked closely at the recent data for Bagana Volcano. \nSatellite data from 15 June show that an increase in lava effusion caused the upper part of the active lava flow at the summit to inflate and likely contributed to the 19-20 June collapse. By 16 June, the inflated area was about 260-meters-long by 140-meters-wide. The northeast edge of the inflated section of the lava flow was the source of rockfalls down the northeast flank where some of the larger blocks tumbled over 1.3 kilometer from the flow. The 22 June data that Julie mentioned shows that all of the inflated area was removed by the collapse (~36,000 square meter area of the upper flow removed). The collapse PDC deposits scoured a deep gorge and removed a broad area of vegetation near the lake. The distal portion of the collapse deposits formed a small delta extending into the lake and possibly raising the lake level slightly based on overall shore outline changes.\n\nI've attached a sketch map showing the features described above. Please let us know if you have any questions.\n\nBest wishes,\nRick", + sender: "rwessels@usgs.gov", + files: [ + { + name: "Bagana-PG-sketch-20260622_collapse+lava.jpg", + url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_collapse+lava.jpg", + type: "image" + }, + { + name: "Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg", + url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg", + type: "image" + }, + { + name: "Bagana_22_Jun_2026_collapse2.kml", + url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana_22_Jun_2026_collapse2.kml", + type: "download" + } + ] + }, + { + id: 1845, + volcano_name: "Lewotolok", + country: "Indonesia", + report_time: "2026-01-07T21:42:17Z", + summary: "Dear friends,\nThe increased activity at Lewotolok Volcano over the past week reminded me that we have not sent you a Remote Sensing Summary since June 2025. Over the two days from 3 to 5 January 2026, explosions from the central cone intensified, doubling the area impacted by proximal ballistics and tephra to over 20 hectares, while increased effusion has produced a new ~20,000 m² lava flow on the west–northwest flank that extends ~270 meters from the vent. So far, all recent lava flows have been confined to the summit crater.\n\nPlease let us know if you have any questions or additions.\n\nBest Wishes,\nRick", + sender: "rwessels@usgs.gov", + files: [] + }, + ]) + }, []); + + const data = useMemo(() => { + if (apiData?.length === 0) return []; + let sorted; + + if (sorts?.volcanoSort && sorts?.timeSort === "asc") { + sorted = [...apiData].sort(ascTimeVolcanoSort); + } else if (sorts?.volcanoSort && sorts?.timeSort === "desc") { + sorted = [...apiData].sort(descTimeVolcanoSort); + } else if (sorts?.timeSort === "asc") { + sorted = [...apiData].sort(ascTimeSort); + } else { + sorted = [...apiData].sort(descTimeSort); + } + + return sorted; + }, [apiData, sorts]); + + useEffect(() => { + if(isCopy) { + const dataText = JSON.stringify(data); // TODO: this is pretty ugly when copied and pasted, format it better + navigator.clipboard.writeText(dataText) + .then(() => { + alert('Copied to clipboard!'); + }) + .catch((err => { + console.error('Failed to copy: ', err); + })); + updateSettings(widgetID, {copyToClipboard: false}); + } + }, [isCopy, 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) => { + 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} +
+ {item?.files?.map((file, index) => { + if (file?.type === "pdf") return ( + + + {file?.name} + PDF + + + + + Your browser cannot display this PDF directly. + + + Download PDF + + + +
+
+ ); + + else if (file?.type === "image") return ( + + + {file?.name} + PNG + + + {file?.name} + +
+
+ ); + + else if (file?.type === "download") return ( + + {file?.name} + + + ) + })} +
+
+ + )} +
+
+ ) + })} +
+ ); +} + + +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/settings/RemoteSensingSettings.jsx b/client/src/components/Widgets/settings/RemoteSensingSettings.jsx new file mode 100644 index 0000000..d7541bd --- /dev/null +++ b/client/src/components/Widgets/settings/RemoteSensingSettings.jsx @@ -0,0 +1,46 @@ +import {Button, Flex, Switch} from "@chakra-ui/react"; +import {useDashboardStore} from "@/store/useDashboardStore.jsx"; + +export default function RemoteSensingSettings({ widgetId, currentSettings }) { + const updateSettings = useDashboardStore(state => state.updateWidgetSettings); + + 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 ( + + + + + + + + Sort Oldest to Newest + + + + + + + Sort by Volcano + + + ) +} \ No newline at end of file diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index 912c5f8..f487268 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -61,8 +61,19 @@ 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: { + 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 }, } }, From d9bdb17217a018aa1ed5c25c5459ac663f5393b0 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Mon, 10 Aug 2026 16:22:34 -0700 Subject: [PATCH 09/24] add filtering on dates to all widgets, defualt is past 7 days --- .../components/Widgets/ExplosionEvents.jsx | 19 +- client/src/components/Widgets/IvansAlerts.jsx | 162 +++++++-------- client/src/components/Widgets/PlumeEvents.jsx | 18 +- .../src/components/Widgets/RemoteSensing.jsx | 184 ++++++++++-------- .../settings/ExplosionEventsSettings.jsx | 88 +++++++-- .../Widgets/settings/IvansAlertsSettings.jsx | 68 ++++++- .../Widgets/settings/PlumeEventsSettings.jsx | 66 +++++++ .../settings/RemoteSensingSettings.jsx | 68 ++++++- .../settings/SeismicEventsSettings.jsx | 10 +- client/src/constants/widgetRegistry.jsx | 1 + 10 files changed, 495 insertions(+), 189 deletions(-) create mode 100644 client/src/components/Widgets/settings/PlumeEventsSettings.jsx diff --git a/client/src/components/Widgets/ExplosionEvents.jsx b/client/src/components/Widgets/ExplosionEvents.jsx index 8aad3b7..17e6b68 100644 --- a/client/src/components/Widgets/ExplosionEvents.jsx +++ b/client/src/components/Widgets/ExplosionEvents.jsx @@ -12,8 +12,25 @@ export default function ExplosionEvents({ settings, widgetID }) { const { colorMode } = useColorMode(); const isDark = colorMode === 'dark'; + // Generate url with input dates are default to 7 day range + const url = useMemo(() =>{ + if (settings?.startDate && settings?.endDate) + return '/api/v2/explosion_events?start_date=' + settings?.startDate + '&end_date=' + settings?.endDate; + else { + const base_url = '/api/v2/explosion_events'; + 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; + } + }, [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 index cf7e67c..526152e 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -10,84 +10,89 @@ export default function IvansAlerts({ settings, widgetID }) { const sorts = settings?.activeSorts; // build url once on mount - const seven_day_url = useMemo(() => { - const base_url = '/api/v2/comm_coor_ivans'; - let today = new Date(); - let sevenDaysAgo = new Date(today); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + const url = useMemo(() =>{ + if (settings?.startDate && settings?.endDate) + return '/api/v2/comm_coor_ivans?start_date=' + settings?.startDate + '&end_date=' + settings?.endDate; + else { + const base_url = '/api/v2/comm_coor_ivans'; + let today = new Date(); + let sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA'); - today = today.toLocaleDateString('en-CA'); + sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA'); + today = today.toLocaleDateString('en-CA'); - return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; - }, []); + return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; + } + }, [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: apiD, isLoading, error} = VdapApi(seven_day_url); + const {data: apiData, isLoading, error} = VdapApi(url); - const apiData = useMemo(() => { - return ([ - { - 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: 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: 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" - } - ] - } - ]) - }, []); + // TEST DATA + // const apiData = useMemo(() => { + // return ([ + // { + // 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: 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: 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" + // } + // ] + // } + // ]) + // }, []); const data = useMemo(() => { if (apiData?.length === 0) return []; @@ -176,11 +181,17 @@ export default function IvansAlerts({ settings, widgetID }) { {/* This box needs to be at h="100%" for the pdf toolbar to appear */} - {item?.summary} -
+ + {item?.summary} + Preparer: {item?.preparer} + {item?.files?.map((file, index) => { return ( +
+ + {file?.name} + -
)})}
diff --git a/client/src/components/Widgets/PlumeEvents.jsx b/client/src/components/Widgets/PlumeEvents.jsx index 8470814..e61def1 100644 --- a/client/src/components/Widgets/PlumeEvents.jsx +++ b/client/src/components/Widgets/PlumeEvents.jsx @@ -4,7 +4,23 @@ import Plot from 'react-plotly.js'; import {Box, Flex, Spinner, Text} from "@chakra-ui/react"; export default function PlumeEvents({ settings, widgetID}) { - const { data: apiData, isLoading, error } = VdapApi('/api/v2/plume_events?start_date=2026-01-01&end_date=2026-03-01'); + const url = useMemo(() =>{ + if (settings?.startDate && settings?.endDate) + return '/api/v2/plume_events?start_date=' + settings?.startDate + '&end_date=' + settings?.endDate; + else { + const base_url = '/api/v2/plume_events'; + 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; + } + }, [settings?.startDate, settings?.endDate]); + + const { data: apiData, isLoading, error } = VdapApi(url); const plotTraces = useMemo(() => { if (!apiData || apiData.length === 0) return []; diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx index 60cf297..020138f 100644 --- a/client/src/components/Widgets/RemoteSensing.jsx +++ b/client/src/components/Widgets/RemoteSensing.jsx @@ -10,94 +10,99 @@ export default function RemoteSensing({ settings, widgetID }) { const sorts = settings?.activeSorts; // build url once on mount - const seven_day_url = useMemo(() => { - const base_url = '/api/v2/remote_sensing'; - let today = new Date(); - let sevenDaysAgo = new Date(today); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + const url = useMemo(() =>{ + if (settings?.startDate && settings?.endDate) + return '/api/v2/remote_sensing?start_date=' + settings?.startDate + '&end_date=' + settings?.endDate; + else { + const base_url = '/api/v2/remote_sensing'; + let today = new Date(); + let sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA'); - today = today.toLocaleDateString('en-CA'); + sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA'); + today = today.toLocaleDateString('en-CA'); - return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; - }, []); + return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; + } + }, [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: apiD, isLoading, error} = VdapApi(seven_day_url); + const {data: apiData, isLoading, error} = VdapApi(url); - const apiData = useMemo(() => { - return ([ - { - id: 1848, - volcano_name: "Lewotolok", - country: "Indonesia", - report_time: "2026-01-13T20:39:39Z", - summary: "Dear friends,\n\nOur latest satellite view from 13 January (~0900 local time) shows that the lava has began to flow over the crater rim onto the northwest flank (see attached). The lava flow spreads into three lobes from the single source vent on the northwest flank of the active cone. The longest portion of the flow was about 530 meters from the vent with the toe draping about 30-40 meters over the rim of the main crater just north of the May 2024 lava flow. A narrow deposit of volcanoclastic debris extends about 300 meters below the toe of the lava flow (white dashed outline on sketch).\n\nPlease let us know if you have any questions.\nBest wishes,\nRick", - sender: "rwessels@usgs.gov", - files: [ - { - name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", - url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", - type: "image" - }, - { - name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", - url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", - type: "image" - } - ] - }, - { - id: 1847, - volcano_name: "Mayon", - country: "Philippines", - report_time: "2026-01-13T17:56:46Z", - summary: "Dear PHIVOLCS friends,\nOur most recent satellite data from 13 January (1500 local) show the new lava flow extending southeast from the summit and splitting into two lobes (see attached pdf). The total length of the longest lobe was about 1 kilometer. Compared to data from 12 January, the longest part of the flow advanced almost 200 meters over 11 hours while the shorter section advanced about 80 meters over the same time. Debris falling from the front of both lava flow lobes extend another several hundred meters below each lobe. We continue to watch the activity at Mayon volcano and will share anything that might help. \n\nPlease let us know if you have any questions, requests, or comments.\n\nBest wishes,\nRick", - sender: "rwessels@usgs.gov", - files: [ - { - name: "Mayon-RP-images-20260113_ICEYE_lava.pdf", - url: "https://vdap.org/commcoordashboard/rs/attachments/1847-Mayon-RP-images-20260113_ICEYE_lava.pdf", - type: "pdf" - } - ] - }, - { - id: 1889, - volcano_name: "Bagana", - country: "Papua New Guinea", - report_time: "2026-06-24T21:28:58Z", - summary: "Hi Steve and RVO,\nI'm now back in the office after some time away and have looked closely at the recent data for Bagana Volcano. \nSatellite data from 15 June show that an increase in lava effusion caused the upper part of the active lava flow at the summit to inflate and likely contributed to the 19-20 June collapse. By 16 June, the inflated area was about 260-meters-long by 140-meters-wide. The northeast edge of the inflated section of the lava flow was the source of rockfalls down the northeast flank where some of the larger blocks tumbled over 1.3 kilometer from the flow. The 22 June data that Julie mentioned shows that all of the inflated area was removed by the collapse (~36,000 square meter area of the upper flow removed). The collapse PDC deposits scoured a deep gorge and removed a broad area of vegetation near the lake. The distal portion of the collapse deposits formed a small delta extending into the lake and possibly raising the lake level slightly based on overall shore outline changes.\n\nI've attached a sketch map showing the features described above. Please let us know if you have any questions.\n\nBest wishes,\nRick", - sender: "rwessels@usgs.gov", - files: [ - { - name: "Bagana-PG-sketch-20260622_collapse+lava.jpg", - url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_collapse+lava.jpg", - type: "image" - }, - { - name: "Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg", - url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg", - type: "image" - }, - { - name: "Bagana_22_Jun_2026_collapse2.kml", - url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana_22_Jun_2026_collapse2.kml", - type: "download" - } - ] - }, - { - id: 1845, - volcano_name: "Lewotolok", - country: "Indonesia", - report_time: "2026-01-07T21:42:17Z", - summary: "Dear friends,\nThe increased activity at Lewotolok Volcano over the past week reminded me that we have not sent you a Remote Sensing Summary since June 2025. Over the two days from 3 to 5 January 2026, explosions from the central cone intensified, doubling the area impacted by proximal ballistics and tephra to over 20 hectares, while increased effusion has produced a new ~20,000 m² lava flow on the west–northwest flank that extends ~270 meters from the vent. So far, all recent lava flows have been confined to the summit crater.\n\nPlease let us know if you have any questions or additions.\n\nBest Wishes,\nRick", - sender: "rwessels@usgs.gov", - files: [] - }, - ]) - }, []); + // TEST DATA + // const apiData = useMemo(() => { + // return ([ + // { + // id: 1848, + // volcano_name: "Lewotolok", + // country: "Indonesia", + // report_time: "2026-01-13T20:39:39Z", + // summary: "Dear friends,\n\nOur latest satellite view from 13 January (~0900 local time) shows that the lava has began to flow over the crater rim onto the northwest flank (see attached). The lava flow spreads into three lobes from the single source vent on the northwest flank of the active cone. The longest portion of the flow was about 530 meters from the vent with the toe draping about 30-40 meters over the rim of the main crater just north of the May 2024 lava flow. A narrow deposit of volcanoclastic debris extends about 300 meters below the toe of the lava flow (white dashed outline on sketch).\n\nPlease let us know if you have any questions.\nBest wishes,\nRick", + // sender: "rwessels@usgs.gov", + // files: [ + // { + // name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", + // url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", + // type: "image" + // }, + // { + // name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", + // url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", + // type: "image" + // } + // ] + // }, + // { + // id: 1847, + // volcano_name: "Mayon", + // country: "Philippines", + // report_time: "2026-01-13T17:56:46Z", + // summary: "Dear PHIVOLCS friends,\nOur most recent satellite data from 13 January (1500 local) show the new lava flow extending southeast from the summit and splitting into two lobes (see attached pdf). The total length of the longest lobe was about 1 kilometer. Compared to data from 12 January, the longest part of the flow advanced almost 200 meters over 11 hours while the shorter section advanced about 80 meters over the same time. Debris falling from the front of both lava flow lobes extend another several hundred meters below each lobe. We continue to watch the activity at Mayon volcano and will share anything that might help. \n\nPlease let us know if you have any questions, requests, or comments.\n\nBest wishes,\nRick", + // sender: "rwessels@usgs.gov", + // files: [ + // { + // name: "Mayon-RP-images-20260113_ICEYE_lava.pdf", + // url: "https://vdap.org/commcoordashboard/rs/attachments/1847-Mayon-RP-images-20260113_ICEYE_lava.pdf", + // type: "pdf" + // } + // ] + // }, + // { + // id: 1889, + // volcano_name: "Bagana", + // country: "Papua New Guinea", + // report_time: "2026-06-24T21:28:58Z", + // summary: "Hi Steve and RVO,\nI'm now back in the office after some time away and have looked closely at the recent data for Bagana Volcano. \nSatellite data from 15 June show that an increase in lava effusion caused the upper part of the active lava flow at the summit to inflate and likely contributed to the 19-20 June collapse. By 16 June, the inflated area was about 260-meters-long by 140-meters-wide. The northeast edge of the inflated section of the lava flow was the source of rockfalls down the northeast flank where some of the larger blocks tumbled over 1.3 kilometer from the flow. The 22 June data that Julie mentioned shows that all of the inflated area was removed by the collapse (~36,000 square meter area of the upper flow removed). The collapse PDC deposits scoured a deep gorge and removed a broad area of vegetation near the lake. The distal portion of the collapse deposits formed a small delta extending into the lake and possibly raising the lake level slightly based on overall shore outline changes.\n\nI've attached a sketch map showing the features described above. Please let us know if you have any questions.\n\nBest wishes,\nRick", + // sender: "rwessels@usgs.gov", + // files: [ + // { + // name: "Bagana-PG-sketch-20260622_collapse+lava.jpg", + // url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_collapse+lava.jpg", + // type: "image" + // }, + // { + // name: "Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg", + // url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg", + // type: "image" + // }, + // { + // name: "Bagana_22_Jun_2026_collapse2.kml", + // url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana_22_Jun_2026_collapse2.kml", + // type: "download" + // } + // ] + // }, + // { + // id: 1845, + // volcano_name: "Lewotolok", + // country: "Indonesia", + // report_time: "2026-01-07T21:42:17Z", + // summary: "Dear friends,\nThe increased activity at Lewotolok Volcano over the past week reminded me that we have not sent you a Remote Sensing Summary since June 2025. Over the two days from 3 to 5 January 2026, explosions from the central cone intensified, doubling the area impacted by proximal ballistics and tephra to over 20 hectares, while increased effusion has produced a new ~20,000 m² lava flow on the west–northwest flank that extends ~270 meters from the vent. So far, all recent lava flows have been confined to the summit crater.\n\nPlease let us know if you have any questions or additions.\n\nBest Wishes,\nRick", + // sender: "rwessels@usgs.gov", + // files: [] + // }, + // ]) + // }, []); const data = useMemo(() => { if (apiData?.length === 0) return []; @@ -185,12 +190,17 @@ export default function RemoteSensing({ settings, widgetID }) { {/* This box needs to be at h="100%" for the pdf toolbar to appear */} - - {item?.summary} -
+ + + {item?.summary} + Source: {item?.sender} + {item?.files?.map((file, index) => { + + // PDF FILE TYPE if (file?.type === "pdf") return ( +
{file?.name} PDF @@ -213,6 +223,7 @@ export default function RemoteSensing({ settings, widgetID }) {
); + // IMAGE FILE TYPE else if (file?.type === "image") return ( @@ -226,6 +237,7 @@ export default function RemoteSensing({ settings, widgetID }) { ); + // DOWNLOADABLE LINK else if (file?.type === "download") return ( {file?.name} 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 index 59a8d6e..a4915f7 100644 --- a/client/src/components/Widgets/settings/IvansAlertsSettings.jsx +++ b/client/src/components/Widgets/settings/IvansAlertsSettings.jsx @@ -1,8 +1,11 @@ -import {Button, Flex, Switch, Text} from "@chakra-ui/react"; +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}); @@ -17,8 +20,11 @@ export default function IvansAlertsSettings({ widgetId, currentSettings }) { } return ( - - + + {/* COPY TO CLIPBOARD */} + + + {/* SORTS */} 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 index d7541bd..15ebb7e 100644 --- a/client/src/components/Widgets/settings/RemoteSensingSettings.jsx +++ b/client/src/components/Widgets/settings/RemoteSensingSettings.jsx @@ -1,8 +1,11 @@ -import {Button, Flex, Switch} from "@chakra-ui/react"; +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}); @@ -17,8 +20,11 @@ export default function RemoteSensingSettings({ widgetId, currentSettings }) { } return ( - - + + {/* COPYING TO CLIPBOARD */} + + + {/* SORTING */} 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 index 03bc0ba..aa53d9e 100644 --- a/client/src/components/Widgets/settings/SeismicEventsSettings.jsx +++ b/client/src/components/Widgets/settings/SeismicEventsSettings.jsx @@ -8,8 +8,8 @@ export default function SeismicEventsSettings({ widgetId, currentSettings }) { const updateSettings = useDashboardStore(state => state.updateWidgetSettings); return ( - - + + Start Date @@ -34,6 +34,8 @@ export default function SeismicEventsSettings({ widgetId, currentSettings }) { {startError && {startError}} + + End Date @@ -58,7 +60,7 @@ export default function SeismicEventsSettings({ widgetId, currentSettings }) { {endError && {endError}} - - + + ) } \ No newline at end of file diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index f487268..039763d 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -111,6 +111,7 @@ export const WIDGET_REGISTRY = { 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: {}, allowedSizes: { medium: { w: 6, h: 12 } From 0c79b409bd87181aecfaa2c2061fb73ce975367a Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Tue, 11 Aug 2026 08:37:08 -0700 Subject: [PATCH 10/24] fix ivans & rs text overflow in header of each entry by using ellipses --- client/src/components/Widgets/IvansAlerts.jsx | 11 ++++++----- client/src/components/Widgets/RemoteSensing.jsx | 15 ++++++++------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index 526152e..5c7c837 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -165,16 +165,17 @@ export default function IvansAlerts({ settings, widgetID }) { w="100%" h="100%" justify="space-between" align="center" pb={1} + gap={2} borderTopRadius="lg" borderBottomRadius={collapsible.open ? "0" : "lg"} bg="gray.700" > - - {item.volcano_name} - {item.send_time} + + {item.volcano_name} + {item.send_time} - - {item.report_num} + + {item.report_num} diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx index 020138f..99e38ea 100644 --- a/client/src/components/Widgets/RemoteSensing.jsx +++ b/client/src/components/Widgets/RemoteSensing.jsx @@ -9,7 +9,7 @@ export default function RemoteSensing({ settings, widgetID }) { const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set const sorts = settings?.activeSorts; - // build url once on mount + // build url const url = useMemo(() =>{ if (settings?.startDate && settings?.endDate) return '/api/v2/remote_sensing?start_date=' + settings?.startDate + '&end_date=' + settings?.endDate; @@ -170,21 +170,22 @@ export default function RemoteSensing({ settings, widgetID }) { {(collapsible) => ( <> - + - - {item?.volcano_name} - {item?.country} + + {item?.volcano_name} + {item?.country} - - {item?.report_time} + + {item?.report_time} From 058b51795bbff557ffe635891a0319c359d12fb8 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Tue, 11 Aug 2026 09:20:07 -0700 Subject: [PATCH 11/24] update data formatting when copying to clipboard ivans or rs --- client/src/components/Widgets/IvansAlerts.jsx | 8 +++++++- client/src/components/Widgets/RemoteSensing.jsx | 11 ++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index 5c7c837..d5488ea 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -113,7 +113,13 @@ export default function IvansAlerts({ settings, widgetID }) { useEffect(() => { if(isCopy) { - const dataText = JSON.stringify(data); // TODO: this is pretty ugly when copied and pasted, format it better + const dataText = 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!'); diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx index 99e38ea..b93e0a0 100644 --- a/client/src/components/Widgets/RemoteSensing.jsx +++ b/client/src/components/Widgets/RemoteSensing.jsx @@ -123,7 +123,12 @@ export default function RemoteSensing({ settings, widgetID }) { useEffect(() => { if(isCopy) { - const dataText = JSON.stringify(data); // TODO: this is pretty ugly when copied and pasted, format it better + 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!'); @@ -180,11 +185,11 @@ export default function RemoteSensing({ settings, widgetID }) { borderBottomRadius={collapsible.open ? "0" : "lg"} bg="gray.700" > - + {item?.volcano_name} {item?.country} - + {item?.report_time} From 2bea05bf29790bab8d682321124c500691988a77 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Tue, 11 Aug 2026 13:11:11 -0700 Subject: [PATCH 12/24] fix spacing issues around images/pdfs in ivans and rs --- client/src/components/Widgets/IvansAlerts.jsx | 12 ++++++------ client/src/components/Widgets/RemoteSensing.jsx | 8 +++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index d5488ea..ee20957 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -132,19 +132,19 @@ export default function IvansAlerts({ settings, widgetID }) { }, [isCopy, data, widgetID, updateSettings]); if (isLoading) return ( - + ); if (error) return ( - + {error} ); if (data.length === 0) return ( - + No flight level data available this week. ); @@ -170,17 +170,17 @@ export default function IvansAlerts({ settings, widgetID }) { - + {item.volcano_name} {item.send_time} - + {item.report_num} diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx index b93e0a0..d2dac1a 100644 --- a/client/src/components/Widgets/RemoteSensing.jsx +++ b/client/src/components/Widgets/RemoteSensing.jsx @@ -181,15 +181,16 @@ export default function RemoteSensing({ settings, widgetID }) { w="100%" h="100%" justify="space-between" align="center" gap={2} + p={1} pl={3} pr={3} borderTopRadius="lg" borderBottomRadius={collapsible.open ? "0" : "lg"} bg="gray.700" > - + {item?.volcano_name} {item?.country} - + {item?.report_time} @@ -225,13 +226,13 @@ export default function RemoteSensing({ settings, widgetID }) {
-
); // IMAGE FILE TYPE else if (file?.type === "image") return ( +
{file?.name} PNG @@ -246,6 +247,7 @@ export default function RemoteSensing({ settings, widgetID }) { // DOWNLOADABLE LINK else if (file?.type === "download") return ( +
{file?.name} */} + {/*
*/}
)})}
From 0381bc3ce872028a5c4cef614b1e3f82f4e751ab Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Wed, 19 Aug 2026 07:47:48 -0700 Subject: [PATCH 16/24] update colors on each widget entry --- client/src/components/Widgets/IvansAlerts.jsx | 11 ++--------- client/src/components/Widgets/RemoteSensing.jsx | 14 +++++++++++--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index e68d909..88a0e3f 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -161,14 +161,14 @@ export default function IvansAlerts({ settings, widgetID }) { gap={2} borderTopRadius="lg" borderBottomRadius={collapsible.open ? "0" : "lg"} - bg="gray.700" + bg="gray.600" > {item.volcano_name} {item.send_time} - {item.report_num} + {item.report_num}
@@ -205,13 +205,6 @@ export default function IvansAlerts({ settings, widgetID }) {
- {/**/} - {/* */} - {/**/} )})} diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx index 2aadaf8..b3607f0 100644 --- a/client/src/components/Widgets/RemoteSensing.jsx +++ b/client/src/components/Widgets/RemoteSensing.jsx @@ -171,14 +171,22 @@ export default function RemoteSensing({ settings, widgetID }) { p={1} pl={3} pr={3} borderTopRadius="lg" borderBottomRadius={collapsible.open ? "0" : "lg"} - bg="gray.700" + bg="gray.600" > - {item?.volcano_name} + {item?.volcano_name} {item?.country} - {item?.report_time} + + {item?.report_time} + From a5f2f012dc4c795a1f1c6461c9e301ac58767a6d Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Wed, 19 Aug 2026 09:52:08 -0700 Subject: [PATCH 17/24] add differentiation in capability according to sizing in ivans widget --- client/src/components/Widgets/IvansAlerts.jsx | 137 ++++++------------ 1 file changed, 45 insertions(+), 92 deletions(-) diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index 88a0e3f..47d2a65 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -1,14 +1,17 @@ import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react"; -import {useEffect, useMemo } from '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("IvansAlerts widget ID: ", widgetID); + console.log("Ivans settings: ", settings, "Ivans widgetID: ", widgetID); const updateSettings = useDashboardStore(state => state.updateWidgetSettings); - const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set - const sorts = settings?.activeSorts; + + // 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); @@ -16,90 +19,21 @@ export default function IvansAlerts({ settings, widgetID }) { // 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); - // TEST DATA - // const apiData = useMemo(() => { - // return ([ - // { - // 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: 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: 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" - // } - // ] - // } - // ]) - // }, []); - + // sort the apiData const data = useMemo(() => { if (apiData?.length === 0) return []; - let sorted; + const sorts = settings?.activeSorts; - if (sorts?.volcanoSort && sorts?.timeSort === "asc") { - sorted = [...apiData].sort(ascTimeVolcanoSort); - } else if (sorts?.volcanoSort && sorts?.timeSort === "desc") { - sorted = [...apiData].sort(descTimeVolcanoSort); - } else if (sorts?.timeSort === "asc") { - sorted = [...apiData].sort(ascTimeSort); - } else { - sorted = [...apiData].sort(descTimeSort); - } + 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); - return sorted; - }, [apiData, sorts]); + }, [apiData, settings]); + // copy data to clipboard in formatted way when user clicks on copy btn in widget settings useEffect(() => { - if(isCopy) { + if (settings?.copyToClipboard === true) { const dataText = data.map((item) => { return `${item.volcano_name}\n` + `Report Number: ${item.report_num}\n` + @@ -116,7 +50,7 @@ export default function IvansAlerts({ settings, widgetID }) { })); updateSettings(widgetID, {copyToClipboard: false}); } - }, [isCopy, data, widgetID, updateSettings]); + }, [settings, data, widgetID, updateSettings]); if (isLoading) return ( @@ -147,21 +81,22 @@ export default function IvansAlerts({ settings, widgetID }) { bg="gray.800" > {data.map((item, index) => { - return ( + if (thisWidget?.size === "large") return ( {/* Using context to dynamically change the border radii of the triggers */} {(collapsible) => ( <> - {item.volcano_name} @@ -214,6 +149,24 @@ export default function IvansAlerts({ settings, widgetID }) { ) + else if (thisWidget?.size === "small") return ( + + + {item.volcano_name} + {item.send_time} + + + {item.report_num} + + + ) })} ); From 1f2b578c91696017761f0f48a7482cf1dbf2e2a6 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Wed, 19 Aug 2026 12:39:24 -0700 Subject: [PATCH 18/24] add differentiating capabilities according to widget size in rs --- .../src/components/Widgets/RemoteSensing.jsx | 177 +++++------------- 1 file changed, 50 insertions(+), 127 deletions(-) diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx index b3607f0..3adbb27 100644 --- a/client/src/components/Widgets/RemoteSensing.jsx +++ b/client/src/components/Widgets/RemoteSensing.jsx @@ -1,14 +1,17 @@ import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react"; -import {useEffect, useMemo } from 'react'; +import {useEffect, useMemo, useCallback} from 'react'; import {useDashboardStore} from "@/store/useDashboardStore.jsx"; -import VdapApi from '../../hooks/useVdapApi.js'; +import VdapApi from '@/hooks/useVdapApi.js'; import useBuildUrl from "@/hooks/useBuildUrl.js"; export default function RemoteSensing({ settings, widgetID }) { - console.log("RS widget ID: ", widgetID); + console.log("RS settings: ", settings, "RS ID: ", widgetID); const updateSettings = useDashboardStore(state => state.updateWidgetSettings); - const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set - const sorts = settings?.activeSorts; + + // 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); @@ -16,100 +19,21 @@ export default function RemoteSensing({ settings, widgetID }) { // 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); - // TEST DATA - // const apiData = useMemo(() => { - // return ([ - // { - // id: 1848, - // volcano_name: "Lewotolok", - // country: "Indonesia", - // report_time: "2026-01-13T20:39:39Z", - // summary: "Dear friends,\n\nOur latest satellite view from 13 January (~0900 local time) shows that the lava has began to flow over the crater rim onto the northwest flank (see attached). The lava flow spreads into three lobes from the single source vent on the northwest flank of the active cone. The longest portion of the flow was about 530 meters from the vent with the toe draping about 30-40 meters over the rim of the main crater just north of the May 2024 lava flow. A narrow deposit of volcanoclastic debris extends about 300 meters below the toe of the lava flow (white dashed outline on sketch).\n\nPlease let us know if you have any questions.\nBest wishes,\nRick", - // sender: "rwessels@usgs.gov", - // files: [ - // { - // name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", - // url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", - // type: "image" - // }, - // { - // name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", - // url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg", - // type: "image" - // } - // ] - // }, - // { - // id: 1847, - // volcano_name: "Mayon", - // country: "Philippines", - // report_time: "2026-01-13T17:56:46Z", - // summary: "Dear PHIVOLCS friends,\nOur most recent satellite data from 13 January (1500 local) show the new lava flow extending southeast from the summit and splitting into two lobes (see attached pdf). The total length of the longest lobe was about 1 kilometer. Compared to data from 12 January, the longest part of the flow advanced almost 200 meters over 11 hours while the shorter section advanced about 80 meters over the same time. Debris falling from the front of both lava flow lobes extend another several hundred meters below each lobe. We continue to watch the activity at Mayon volcano and will share anything that might help. \n\nPlease let us know if you have any questions, requests, or comments.\n\nBest wishes,\nRick", - // sender: "rwessels@usgs.gov", - // files: [ - // { - // name: "Mayon-RP-images-20260113_ICEYE_lava.pdf", - // url: "https://vdap.org/commcoordashboard/rs/attachments/1847-Mayon-RP-images-20260113_ICEYE_lava.pdf", - // type: "pdf" - // } - // ] - // }, - // { - // id: 1889, - // volcano_name: "Bagana", - // country: "Papua New Guinea", - // report_time: "2026-06-24T21:28:58Z", - // summary: "Hi Steve and RVO,\nI'm now back in the office after some time away and have looked closely at the recent data for Bagana Volcano. \nSatellite data from 15 June show that an increase in lava effusion caused the upper part of the active lava flow at the summit to inflate and likely contributed to the 19-20 June collapse. By 16 June, the inflated area was about 260-meters-long by 140-meters-wide. The northeast edge of the inflated section of the lava flow was the source of rockfalls down the northeast flank where some of the larger blocks tumbled over 1.3 kilometer from the flow. The 22 June data that Julie mentioned shows that all of the inflated area was removed by the collapse (~36,000 square meter area of the upper flow removed). The collapse PDC deposits scoured a deep gorge and removed a broad area of vegetation near the lake. The distal portion of the collapse deposits formed a small delta extending into the lake and possibly raising the lake level slightly based on overall shore outline changes.\n\nI've attached a sketch map showing the features described above. Please let us know if you have any questions.\n\nBest wishes,\nRick", - // sender: "rwessels@usgs.gov", - // files: [ - // { - // name: "Bagana-PG-sketch-20260622_collapse+lava.jpg", - // url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_collapse+lava.jpg", - // type: "image" - // }, - // { - // name: "Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg", - // url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg", - // type: "image" - // }, - // { - // name: "Bagana_22_Jun_2026_collapse2.kml", - // url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana_22_Jun_2026_collapse2.kml", - // type: "download" - // } - // ] - // }, - // { - // id: 1845, - // volcano_name: "Lewotolok", - // country: "Indonesia", - // report_time: "2026-01-07T21:42:17Z", - // summary: "Dear friends,\nThe increased activity at Lewotolok Volcano over the past week reminded me that we have not sent you a Remote Sensing Summary since June 2025. Over the two days from 3 to 5 January 2026, explosions from the central cone intensified, doubling the area impacted by proximal ballistics and tephra to over 20 hectares, while increased effusion has produced a new ~20,000 m² lava flow on the west–northwest flank that extends ~270 meters from the vent. So far, all recent lava flows have been confined to the summit crater.\n\nPlease let us know if you have any questions or additions.\n\nBest Wishes,\nRick", - // sender: "rwessels@usgs.gov", - // files: [] - // }, - // ]) - // }, []); - + // sort apiData const data = useMemo(() => { if (apiData?.length === 0) return []; - let sorted; + const sorts = settings?.activeSorts; - if (sorts?.volcanoSort && sorts?.timeSort === "asc") { - sorted = [...apiData].sort(ascTimeVolcanoSort); - } else if (sorts?.volcanoSort && sorts?.timeSort === "desc") { - sorted = [...apiData].sort(descTimeVolcanoSort); - } else if (sorts?.timeSort === "asc") { - sorted = [...apiData].sort(ascTimeSort); - } else { - sorted = [...apiData].sort(descTimeSort); - } + 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); - return sorted; - }, [apiData, sorts]); + }, [apiData, settings]); + // check if user wants to copy all RS useEffect(() => { - if(isCopy) { + if(settings?.copyToClipboard === true) { const dataText = data.map((item) => { return `${item.country}, ${item.volcano_name}\n` + `Report Time: ${item.report_time}\n` + @@ -125,7 +49,7 @@ export default function RemoteSensing({ settings, widgetID }) { })); updateSettings(widgetID, {copyToClipboard: false}); } - }, [isCopy, data, widgetID, updateSettings]); + }, [settings, data, widgetID, updateSettings]); if (isLoading) return ( @@ -146,45 +70,24 @@ export default function RemoteSensing({ settings, widgetID }) { ); return ( - + {data.map((item, index) => { - return ( + 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} @@ -235,19 +138,20 @@ export default function RemoteSensing({ settings, widgetID }) { {file?.name} -
); // DOWNLOADABLE LINK else if (file?.type === "download") return ( - + <>
- {file?.name} - -
+ + {file?.name} + + + ) })} @@ -257,6 +161,25 @@ export default function RemoteSensing({ settings, widgetID }) {
) + else if (thisWidget?.size === "small") return ( + + + {item?.volcano_name} + {item?.country} + + + + {item?.report_time} + + + + ) })}
); From 152aacb1c3316db3c08a38c25f5d89130af8dede Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Thu, 20 Aug 2026 16:49:50 -0700 Subject: [PATCH 19/24] add comm coor report w/ download and copy abilities --- .../src/components/Widgets/CommCoorReport.jsx | 140 ++++++++++++++++++ .../settings/CommCoorReportSettings.jsx | 83 +++++++++++ client/src/constants/widgetRegistry.jsx | 19 ++- client/src/hooks/useVdapApi.js | 2 +- 4 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 client/src/components/Widgets/CommCoorReport.jsx create mode 100644 client/src/components/Widgets/settings/CommCoorReportSettings.jsx 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/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/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index dd6fbfe..2118963 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -159,7 +159,24 @@ export const WIDGET_REGISTRY = { 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 }, } } diff --git a/client/src/hooks/useVdapApi.js b/client/src/hooks/useVdapApi.js index ed369db..0f780b1 100644 --- a/client/src/hooks/useVdapApi.js +++ b/client/src/hooks/useVdapApi.js @@ -26,7 +26,7 @@ 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) { From 6304db66c2ca1b367b44fa52440019a2048cd0b3 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Thu, 27 Aug 2026 12:36:25 -0700 Subject: [PATCH 20/24] add working vaa ash-polygons map w/ volcano selection capabilities --- client/src/components/Widgets/IvansAlerts.jsx | 24 +-- client/src/components/Widgets/VaaMap.jsx | 175 ++++++++++++++++++ .../Widgets/settings/VaaMapSettings.jsx | 62 +++++++ client/src/constants/widgetRegistry.jsx | 24 +++ client/src/hooks/useVdapApi.js | 2 +- 5 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 client/src/components/Widgets/VaaMap.jsx create mode 100644 client/src/components/Widgets/settings/VaaMapSettings.jsx diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index 47d2a65..48451a7 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -17,24 +17,24 @@ export default function IvansAlerts({ settings, widgetID }) { 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: apiData, isLoading, error} = VdapApi(url); + const {data, isLoading, error} = VdapApi(url); // sort the apiData - const data = useMemo(() => { - if (apiData?.length === 0) return []; + const some_data = useMemo(() => { + if (data?.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); + 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); - }, [apiData, settings]); + }, [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 = data.map((item) => { + const dataText = some_data.map((item) => { return `${item.volcano_name}\n` + `Report Number: ${item.report_num}\n` + `Send Time: ${item.send_time}\n` + @@ -50,7 +50,7 @@ export default function IvansAlerts({ settings, widgetID }) { })); updateSettings(widgetID, {copyToClipboard: false}); } - }, [settings, data, widgetID, updateSettings]); + }, [settings, some_data, widgetID, updateSettings]); if (isLoading) return ( @@ -64,7 +64,7 @@ export default function IvansAlerts({ settings, widgetID }) { ); - if (data.length === 0) return ( + if (some_data.length === 0) return ( No flight level data available for the selected dates. @@ -80,7 +80,7 @@ export default function IvansAlerts({ settings, widgetID }) { borderRadius="xl" bg="gray.800" > - {data.map((item, index) => { + {some_data.map((item, index) => { if (thisWidget?.size === "large") return ( {/* Using context to dynamically change the border radii of the triggers */} diff --git a/client/src/components/Widgets/VaaMap.jsx b/client/src/components/Widgets/VaaMap.jsx new file mode 100644 index 0000000..61a6bdd --- /dev/null +++ b/client/src/components/Widgets/VaaMap.jsx @@ -0,0 +1,175 @@ +import React, { 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": "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 " + } + }, + { + "vaa_datetime": "2026-08-25T01:01:00Z", + "vnum": 282080, + "volc_name": "Aira", + "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": "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" + }, + }, + ] + , []); + + const {data, isLoading, error, selected} = 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; + const selected = settings?.selected; + return {data, isLoading, error, selected}; + }, [settings]); + + + // convert Degree & Minutes (DM) to Decimal Degrees (DD) + const polygons = useMemo(() => { + if (!mock_data) return []; + return mock_data.map((vaa) => { + 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' }, + } + + if (isLoading) return ( + + + + ); + + if (error) return ( + + {error} + + ); + + if (data?.length === 0) return ( + + No flight level data available. + + ); + + return ( + + + {polygons?.map((polygon, index) => { + return polygon?.projections?.map((coordinates, index) => + + {polygon?.volc_name} + + ) + })} + + + ) +} + +function convertDMtoDD(DMString) { + const cleaned_DMString = DMString.trim().replace(/-/g, "").split(/\s+/); + + const datetime = cleaned_DMString[0]; + const flight_level = Number(String(cleaned_DMString[1]).slice(6)) * 100; // in feet + + const coordinates = cleaned_DMString.slice(2).map((coordinate, index) => { + 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, selected, settings }) { + const map = useMap(); + + useEffect(() => { + let targetBounds; + + if (!selected && settings?.position) targetBounds = settings.position; + else if (selected) { + const vaaObject = polygons.find((vaa) => vaa.volc_name === selected); + targetBounds = vaaObject?.projections?.[0]; + } + + 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, selected, polygons, settings]); +} \ 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..b890c3d --- /dev/null +++ b/client/src/components/Widgets/settings/VaaMapSettings.jsx @@ -0,0 +1,62 @@ +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); + + const unique_data = useMemo(() => { + if (!data || !Array.isArray(data)) return []; + const key = 'volc_name'; + return [...new Map(data.map(item => [item[key], item])).values()]; + }, [data]); + + const filteredItems = useMemo(() => { + if (!inputValue.trim()) return unique_data; + return unique_data.filter((item) => item.volc_name.toLowerCase().includes(inputValue.toLowerCase())); + }, [unique_data, inputValue]); + + const collection = useMemo(() => { + return createListCollection({ + items: filteredItems.sort((a, b) => a.volc_name.localeCompare(b.volc_name)), + itemToString: (item) => item.volc_name, + itemToValue: (item) => item.volc_name, + }); + }, [filteredItems]); + + useEffect(() => { + updateSettings(widgetId, {data: unique_data, isLoading: isLoading, error: error, selected: inputValue}); + }, [updateSettings, widgetId, unique_data, isLoading, error, inputValue]); + + + return ( + setInputValue(e.inputValue)}> + Select a Volcano + + + + + + + + + + + No VAA's found + {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 2118963..11cc95c 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -7,6 +7,10 @@ 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 @@ -178,6 +182,26 @@ export const WIDGET_REGISTRY = { allowedSizes: { medium: { w: 3, h: 12 }, } + }, + + "vaa_map": { + name: "VAA Ash Polygon 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], + startDate: getToday(), + endDate: getToday(), + data: [], + isLoading: false, + isError: undefined, + selected: undefined, + }, + allowedSizes: { + medium: { w: 6, h: 12 } + } } }; \ No newline at end of file diff --git a/client/src/hooks/useVdapApi.js b/client/src/hooks/useVdapApi.js index 0f780b1..850cb09 100644 --- a/client/src/hooks/useVdapApi.js +++ b/client/src/hooks/useVdapApi.js @@ -30,7 +30,7 @@ export default function useWidgetApi(endpoint) { 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); From 7e0723a776efbfdfbf0d1462f8ffdbb323dde2a3 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Fri, 28 Aug 2026 13:11:56 -0700 Subject: [PATCH 21/24] add - descriptive tooltips and default to most recent vaa on mount --- client/src/components/Widgets/VaaMap.jsx | 111 ++++++++++++------ .../Widgets/settings/VaaMapSettings.jsx | 53 ++++++--- client/src/constants/widgetRegistry.jsx | 5 +- 3 files changed, 115 insertions(+), 54 deletions(-) diff --git a/client/src/components/Widgets/VaaMap.jsx b/client/src/components/Widgets/VaaMap.jsx index 61a6bdd..1ea059e 100644 --- a/client/src/components/Widgets/VaaMap.jsx +++ b/client/src/components/Widgets/VaaMap.jsx @@ -1,4 +1,4 @@ -import React, { useRef, useMemo, useEffect } from "react"; +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"; @@ -25,15 +25,15 @@ export default function VaaMap({ settings, widgetID}) { "obs_h_avs_m": 504.0, "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 " + "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": "Aira", + "volc_name": "Sabancaya", "volc_elev_m": 1117, "vaac_location": "Tokyo", "obs_va_datetime": "2026-08-25T00:50:00Z", @@ -45,27 +45,25 @@ export default function VaaMap({ settings, widgetID}) { "obs_h_avs_m": 4369, "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" + "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, selected} = useMemo(() => { + 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; - const selected = settings?.selected; - return {data, isLoading, error, selected}; - }, [settings]); - + 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 []; - return mock_data.map((vaa) => { + 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); @@ -79,6 +77,12 @@ export default function VaaMap({ settings, widgetID}) { 2: { fillColor: 'blue' }, } + const hourCode = { + 0: "+6 hours", + 1: "+12 hours", + 2: "+18 hours", + } + if (isLoading) return ( @@ -93,14 +97,12 @@ export default function VaaMap({ settings, widgetID}) { if (data?.length === 0) return ( - No flight level data available. + No VAA data available. ); return ( - {polygons?.map((polygon, index) => { + {polygons?.map((polygon) => { return polygon?.projections?.map((coordinates, index) => - {polygon?.volc_name} + + + + {polygon?.volc_name} + {hourCode[index]} + + + VAA Datetime: + {polygon?.vaa_datetime} + + + VAAC Location: + {polygon?.vaac_location} + + + Height From Vent: + {polygon?.obs_h_avs_m} (m) + + + ) })} - + ) } @@ -126,10 +147,7 @@ export default function VaaMap({ settings, widgetID}) { function convertDMtoDD(DMString) { const cleaned_DMString = DMString.trim().replace(/-/g, "").split(/\s+/); - const datetime = cleaned_DMString[0]; - const flight_level = Number(String(cleaned_DMString[1]).slice(6)) * 100; // in feet - - const coordinates = cleaned_DMString.slice(2).map((coordinate, index) => { + 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)); @@ -146,30 +164,49 @@ function convertDMtoDD(DMString) { return polygon; } -function ZoomToPolygon({ polygons, selected, settings }) { +function ZoomToPolygon({ polygons, settings }) { const map = useMap(); useEffect(() => { let targetBounds; + let default_vaa_object; + const selectedVAA = settings?.selected; + const defaultVAA = settings?.defaultVAA; - if (!selected && settings?.position) targetBounds = settings.position; - else if (selected) { - const vaaObject = polygons.find((vaa) => vaa.volc_name === selected); - targetBounds = vaaObject?.projections?.[0]; + // 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, - }); + 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, selected, polygons, settings]); + }, [map, polygons, settings.position, settings.selected, settings.defaultVAA]); } \ No newline at end of file diff --git a/client/src/components/Widgets/settings/VaaMapSettings.jsx b/client/src/components/Widgets/settings/VaaMapSettings.jsx index b890c3d..15fe27f 100644 --- a/client/src/components/Widgets/settings/VaaMapSettings.jsx +++ b/client/src/components/Widgets/settings/VaaMapSettings.jsx @@ -11,32 +11,55 @@ export default function VaaMapSettings({widgetId, currentSettings}) { const url = useBuildUrl("api/v2/comm_coor_vaa", currentSettings?.startDate, currentSettings?.endDate); const {data, isLoading, error} = VdapApi(url); - const unique_data = useMemo(() => { - if (!data || !Array.isArray(data)) return []; - const key = 'volc_name'; - return [...new Map(data.map(item => [item[key], item])).values()]; + // 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]); - const filteredItems = useMemo(() => { - if (!inputValue.trim()) return unique_data; - return unique_data.filter((item) => item.volc_name.toLowerCase().includes(inputValue.toLowerCase())); - }, [unique_data, inputValue]); + 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]); - const collection = useMemo(() => { + // 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: filteredItems.sort((a, b) => a.volc_name.localeCompare(b.volc_name)), + items: filtered_vaas?.sort((a, b) => a.volc_name.localeCompare(b.volc_name)), itemToString: (item) => item.volc_name, itemToValue: (item) => item.volc_name, }); - }, [filteredItems]); + }, [filtered_vaas]); + // update state w/ selected vaa so map zooms to polygon useEffect(() => { - updateSettings(widgetId, {data: unique_data, isLoading: isLoading, error: error, selected: inputValue}); - }, [updateSettings, widgetId, unique_data, isLoading, error, inputValue]); + updateSettings(widgetId, {selected: inputValue}); + }, [updateSettings, widgetId, inputValue]); return ( - setInputValue(e.inputValue)}> + setInputValue(e.inputValue)}> Select a Volcano @@ -49,7 +72,7 @@ export default function VaaMapSettings({widgetId, currentSettings}) { No VAA's found - {collection.items.map((vaa) => + {vaa_collection.items.map((vaa) => {vaa?.volc_name} diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index 11cc95c..da9466a 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -185,19 +185,20 @@ export const WIDGET_REGISTRY = { }, "vaa_map": { - name: "VAA Ash Polygon 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], + 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 } From 43cc39fd62e0da65b6da7a870e1bc6c136eee58d Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Tue, 1 Sep 2026 09:47:54 -0700 Subject: [PATCH 22/24] =?UTF-8?q?add=20=E2=80=94=20settings=20page=20has?= =?UTF-8?q?=20basic=20ui,=20changes=20to=20preferences=20currently=20do=20?= =?UTF-8?q?not=20take=20effect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.jsx | 3 + client/src/pages/auth/Settings.jsx | 126 +++++++++++++++++++++++++++++ client/src/store/authStore.js | 17 +++- 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/auth/Settings.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 95e89c7..e5d2afd 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,6 +18,7 @@ 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"; function AppFoundation() { const recipe = useRecipe({ key: "dashboard" }); @@ -81,6 +82,8 @@ function App() { } /> + } /> + } /> } /> } /> diff --git a/client/src/pages/auth/Settings.jsx b/client/src/pages/auth/Settings.jsx new file mode 100644 index 0000000..bd28e4c --- /dev/null +++ b/client/src/pages/auth/Settings.jsx @@ -0,0 +1,126 @@ +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 ( +
+ + + {/* CONTENT */} + + Personal Information + + {/* avatar */} + + + + + Name + {user.firstName || "first name"}, {user.lastName || "last name"} + + + + + + {/* personal info */} + + + Username + + + + + Email + + + + + + + Descipline + + + + + + + + + + + + {/* filler so that discipline dropdown sizes correctly */} + + + + + + {/* permissions */} + + Permissions + + Request access to IVANS + + + + Request access to Remote Sensing + + + + + + + {/* themes */} + + Theme + + Appearance + + setValue(e.currentTarget.value)} + > + + + + + + + + + + {/* SAVE-ALL BUTTON */} + {/* TODO: grey out button until changes are detected */} + + +
+ ); +} 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 From 6c0664d05ad6a338e24466f6399bb46fad860386 Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Wed, 2 Sep 2026 12:44:38 -0700 Subject: [PATCH 23/24] add - base ux ui for web admin page, form works on group and teams selection only --- client/src/App.jsx | 2 + client/src/pages/auth/Settings.jsx | 31 ++-- client/src/pages/auth/WebAdmin.jsx | 234 +++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 client/src/pages/auth/WebAdmin.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index e5d2afd..36b287a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -19,6 +19,7 @@ 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"; function AppFoundation() { const recipe = useRecipe({ key: "dashboard" }); @@ -83,6 +84,7 @@ function App() {
} /> + } /> } /> } /> diff --git a/client/src/pages/auth/Settings.jsx b/client/src/pages/auth/Settings.jsx index bd28e4c..0e5129b 100644 --- a/client/src/pages/auth/Settings.jsx +++ b/client/src/pages/auth/Settings.jsx @@ -23,25 +23,30 @@ export default function Settings() { return (
- + {/* CONTENT */} - Personal Information - - {/* avatar */} + {/* header */} + Settings + + + + {/* personal info */} + + Personal Information + {/* profile picture & name */} - - Name - {user.firstName || "first name"}, {user.lastName || "last name"} - + + Name + {user.firstName || "first name"}, {user.lastName || "last name"} + - {/* personal info */} Username @@ -79,9 +84,9 @@ export default function Settings() { - {/* permissions */} + {/* permission requests */} - Permissions + Permissions Request access to IVANS @@ -96,7 +101,7 @@ export default function Settings() { {/* themes */} - Theme + Theme Appearance @@ -115,7 +120,7 @@ export default function Settings() { - {/* SAVE-ALL BUTTON */} + {/* SAVE-ALL PREFERENCES BUTTON */} {/* TODO: grey out button until changes are detected */} + + + ) +} \ No newline at end of file From c7dcf193f905bf2f4b77da4bf0537406ca387f1d Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Tue, 15 Sep 2026 14:04:35 -0700 Subject: [PATCH 24/24] added gantt chart to widget and it is all visible however the participants column is ugly and data is only dummy data --- client/package-lock.json | 390 ++++++++++++++++++ client/package.json | 1 + .../components/Widgets/TravelGanttChart.jsx | 92 +++++ client/src/constants/widgetRegistry.jsx | 10 + client/src/css/index.css | 4 + 5 files changed, 497 insertions(+) create mode 100644 client/src/components/Widgets/TravelGanttChart.jsx 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/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/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index da9466a..93919fa 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -203,6 +203,16 @@ export const WIDGET_REGISTRY = { 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 } + } } }; \ No newline at end of file diff --git a/client/src/css/index.css b/client/src/css/index.css index 3725a93..4e0259e 100644 --- a/client/src/css/index.css +++ b/client/src/css/index.css @@ -1,3 +1,7 @@ :root { font-family: Georgia, 'Times New Roman', Times, serif; +} + +.wx-theme { + height: 100%; } \ No newline at end of file