From 7f92ddd62bcb40e21d0bf0be239dd6d842f1d01e Mon Sep 17 00:00:00 2001 From: "Kuchenik, Venessa J" Date: Tue, 18 Aug 2026 21:20:49 +0000 Subject: [PATCH] add alert-level-changes widget with working time range selector --- .../src/components/Widgets/AlertChanges.jsx | 219 ++++++++++++++++++ .../components/Widgets/ExplosionEvents.jsx | 21 +- client/src/components/Widgets/IvansAlerts.jsx | 21 +- client/src/components/Widgets/PlumeEvents.jsx | 19 +- .../src/components/Widgets/RemoteSensing.jsx | 19 +- .../src/components/Widgets/SeismicEvents.jsx | 20 +- .../Widgets/settings/AlertChangeSettings.jsx | 36 +++ client/src/constants/widgetRegistry.jsx | 33 ++- client/src/hooks/useBuildUrl.js | 16 ++ 9 files changed, 318 insertions(+), 86 deletions(-) create mode 100644 client/src/components/Widgets/AlertChanges.jsx create mode 100644 client/src/components/Widgets/settings/AlertChangeSettings.jsx create mode 100644 client/src/hooks/useBuildUrl.js diff --git a/client/src/components/Widgets/AlertChanges.jsx b/client/src/components/Widgets/AlertChanges.jsx new file mode 100644 index 0000000..9f6dbd2 --- /dev/null +++ b/client/src/components/Widgets/AlertChanges.jsx @@ -0,0 +1,219 @@ +import {useDashboardStore} from "@/store/useDashboardStore.jsx"; +import VdapApi from "../../hooks/useVdapApi.js"; +import useBuildUrl from "../../hooks/useBuildUrl.js"; +import { Footprints, Plane, Sailboat, TrendingUp, TrendingDown } from 'lucide-react'; +import {Box, Collapsible, Flex, Spinner, Text, Timeline, Center} from "@chakra-ui/react"; +import { useMemo, useCallback } from "react"; + + +export default function AlertChanges({ settings, widgetID}) { + // Find this specific widgets object in the state list of active & displayed widgets + const thisWidget = useDashboardStore(useCallback((state) => + state.widgets.find((widget) => widget.id === widgetID), [widgetID] + )); + + // build url, default is 7 days past + const url = useBuildUrl('/api/v2/alert_changes', settings?.startDate, settings?.endDate); + + // call api hook to retrieve data + const {data: apiData, isLoading, error} = VdapApi(url); + + const data = useMemo(() => { + if (apiData.length === 0) return []; + + // .reduce() is a built-in array method used to transform an entire array into a single value (like an object) + // here it returns a single massive object w/ volcano names as keys + const grouped = apiData.reduce((accumulator, item) => { + if (!accumulator[item.volcano]) accumulator[item.volcano] = []; + accumulator[item.volcano].push(item); + return accumulator; + }, {}); + + // converts the 'grouped' object back into an array of [key, value] pairs + const group_arr = Object.entries(grouped); + + // sort data newest to oldest within each volcano + const sorted = group_arr.map((item) => + [ + item[0], // volcano name + [...(item[1])].sort((a, b) => + new Date(b.alert_start_date_local) - new Date(a.alert_start_date_local)) + ] + ); + + // volcano with most recent alert level change appears at top of list + const sorted_entries = [...sorted].sort((a, b) => new Date(b[1][0].alert_start_date_local) - new Date(a[1][0].alert_start_date_local)); + + if (thisWidget?.size === "small") { + return (sorted_entries.map((volcano) => { + const domain = volcano[1].reduce((acc, curr) => { + if (!acc[curr.alert_scheme_domain]) acc[curr.alert_scheme_domain] = []; + acc[curr.alert_scheme_domain].push(curr); + return acc; + }, {}); + + const domain_arr = Object.entries(domain); + return [volcano[0], domain_arr]; + })); + } + + return sorted_entries; + }, [apiData, thisWidget]); + + // list of alert colors used for coloring text below + const alertColors = { + Red: "red.400", + Orange: "orange.400", + Yellow: "yellow.400", + Green: "green.400", + }; + + if (isLoading) return ( + + + + ); + + if (error) return ( + + {error} + + ); + + if (data.length === 0) return ( + + No flight level data available for the selected dates. + + ); + + return ( + + {data.map((volcano, index) => { + if (thisWidget?.size === "large") return ( + + {/* Using context to dynamically change the border radii of the triggers */} + + {(collapsible) => ( + <> + + + + {volcano[0]} {/* volcano name */} + {volcano[1][0]?.alert_start_date_local} + + + {volcano[1][0]?.alert_level_name} + + + + + + {volcano[1]?.map((object, index) => ( + + + {/**/} + + {object?.alert_scheme_domain === "terrestrial" && } + {object?.alert_scheme_domain === "aviation" && } + {object?.alert_scheme_domain === "maritime" && } + + + {object?.change_type === "increase" ? : } + + + + {/*{object?.volcano}*/} + + + {object?.alert_start_date_local} + + {object?.alert_level_name} + + + Alert Scale: + {(Array.from({ length: Number(object?.alert_scheme_highest) - Number(object?.alert_scheme_lowest) + 1}, (v, i) => Number(object?.alert_scheme_lowest) + i)) + .map((num) => ( +
+ {num} +
+ )) + } +
+
+
+
+
+ ))} +
+
+ + )} +
+
+ ) + else if (thisWidget?.size === "small") return ( + volcano[1]?.map((domain, index) => ( + + + + + {domain[0] === "aviation" && } + {domain[0] === "terrestrial" && } + {domain[0] === "maritime" && } + + {volcano[0] || ""} {/* volcano name */} + + {domain[1]?.[0]?.alert_start_date_local || ""} + + + + + {domain[1]?.[0]?.change_type === "increase" ? : } + + + {domain[1]?.[0]?.alert_level_name} + + + + )) + ); + })} +
+ ) +} \ No newline at end of file diff --git a/client/src/components/Widgets/ExplosionEvents.jsx b/client/src/components/Widgets/ExplosionEvents.jsx index 17e6b68..2ddbf51 100644 --- a/client/src/components/Widgets/ExplosionEvents.jsx +++ b/client/src/components/Widgets/ExplosionEvents.jsx @@ -2,7 +2,8 @@ import { useMemo } from 'react'; import { Box, Flex, Spinner, Text } from '@chakra-ui/react'; import { useColorMode } from "@/components/ui/color-mode.jsx"; import Plot from 'react-plotly.js'; -import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed +import VdapApi from '../../hooks/useVdapApi.js'; +import useBuildUrl from "@/hooks/useBuildUrl.js"; export default function ExplosionEvents({ settings, widgetID }) { // Plotly expects 'linear' for normal axes @@ -12,22 +13,8 @@ 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]); + // Generate url with input dates, default to 7 day range + const url = useBuildUrl('/api/v2/explosion_events', settings?.startDate, settings?.endDate); // Clean, single-line data fetching const { data: apiData, isLoading, error } = VdapApi(url); diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx index d0054a7..b6982a5 100644 --- a/client/src/components/Widgets/IvansAlerts.jsx +++ b/client/src/components/Widgets/IvansAlerts.jsx @@ -2,6 +2,7 @@ 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'; +import useBuildUrl from "@/hooks/useBuildUrl.js"; export default function IvansAlerts({ settings, widgetID }) { console.log("IvansAlerts widget ID: ", widgetID); @@ -9,22 +10,8 @@ export default function IvansAlerts({ 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 - 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'); - - return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; - } - }, [settings?.startDate, settings?.endDate]); + // build url, default is 7 days past + const url = useBuildUrl('/api/v2/comm_coor_ivans', settings?.startDate, settings?.endDate); // VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data const {data: apiData, isLoading, error} = VdapApi(url); @@ -145,7 +132,7 @@ export default function IvansAlerts({ settings, widgetID }) { if (data.length === 0) return ( - No flight level data available this week. + No flight level data available for the selected dates. ); diff --git a/client/src/components/Widgets/PlumeEvents.jsx b/client/src/components/Widgets/PlumeEvents.jsx index e61def1..925a96d 100644 --- a/client/src/components/Widgets/PlumeEvents.jsx +++ b/client/src/components/Widgets/PlumeEvents.jsx @@ -2,24 +2,13 @@ import VdapApi from '../../hooks/useVdapApi.js'; import { useMemo } from 'react'; import Plot from 'react-plotly.js'; import {Box, Flex, Spinner, Text} from "@chakra-ui/react"; +import useBuildUrl from "@/hooks/useBuildUrl.js"; export default function PlumeEvents({ settings, widgetID}) { - 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]); + // Build url, defaults to 7 days past + const url = useBuildUrl('/api/v2/plume_events', settings?.startDate, settings?.endDate); + // Retrieve data from api const { data: apiData, isLoading, error } = VdapApi(url); const plotTraces = useMemo(() => { diff --git a/client/src/components/Widgets/RemoteSensing.jsx b/client/src/components/Widgets/RemoteSensing.jsx index d2dac1a..2aadaf8 100644 --- a/client/src/components/Widgets/RemoteSensing.jsx +++ b/client/src/components/Widgets/RemoteSensing.jsx @@ -2,6 +2,7 @@ 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'; +import useBuildUrl from "@/hooks/useBuildUrl.js"; export default function RemoteSensing({ settings, widgetID }) { console.log("RS widget ID: ", widgetID); @@ -9,22 +10,8 @@ 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 - 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'); - - return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; - } - }, [settings?.startDate, settings?.endDate]); + // build url, defaults to 7 days past + const url = useBuildUrl('/api/v2/remote_sensing', settings?.startDate, settings?.endDate); // VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data const {data: apiData, isLoading, error} = VdapApi(url); diff --git a/client/src/components/Widgets/SeismicEvents.jsx b/client/src/components/Widgets/SeismicEvents.jsx index 037ab48..d01dd75 100644 --- a/client/src/components/Widgets/SeismicEvents.jsx +++ b/client/src/components/Widgets/SeismicEvents.jsx @@ -2,25 +2,13 @@ import VdapApi from '../../hooks/useVdapApi.js'; import { useMemo } from 'react'; import Plot from 'react-plotly.js'; import {Box, Flex, Spinner, Text} from "@chakra-ui/react"; +import useBuildUrl from "@/hooks/useBuildUrl.js"; export default function SeismicEvents({ settings, widgetID}) { - 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); + // build url, defaults to 7 days past + const url = useBuildUrl('/api/v2/seismic_events', settings?.startDate, settings?.endDate); - 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); + // retrieve data from api const { data: apiData, isLoading, error } = VdapApi(url); const plotTraces = useMemo(() => { diff --git a/client/src/components/Widgets/settings/AlertChangeSettings.jsx b/client/src/components/Widgets/settings/AlertChangeSettings.jsx new file mode 100644 index 0000000..bc1103b --- /dev/null +++ b/client/src/components/Widgets/settings/AlertChangeSettings.jsx @@ -0,0 +1,36 @@ +import {Flex, Slider, Switch} from "@chakra-ui/react"; +import {useDashboardStore} from "@/store/useDashboardStore.jsx"; + +export default function IvansAlertsSettings({ widgetId, currentSettings }) { + const updateSettings = useDashboardStore(state => state.updateWidgetSettings); + + return ( + + {/* SLIDER DATE RANGE SELECTION */} + { + const day = new Date(Date.now() - (e.value[0])*86400000).toLocaleDateString('en-CA'); + console.log("new start: ", day); + updateSettings(widgetId, {...currentSettings, startDate: day}); + }} + > + {currentSettings?.endDate} to {currentSettings?.startDate} + + + + + + + today + 1 week ago + 2 weeks ago + + + + + ) +} \ No newline at end of file diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx index 039763d..dd6fbfe 100644 --- a/client/src/constants/widgetRegistry.jsx +++ b/client/src/constants/widgetRegistry.jsx @@ -7,6 +7,9 @@ import { lazy } from 'react'; // w: 4 = one-third screen width // w: 3 = one-quarter screen width +const getStartDate = () => (new Date(Date.now() - 604800000)).toLocaleDateString('en-CA'); // 7 days = 604800000 = 7*24*60*60*1000 +const getEndDate = () => (new Date()).toLocaleDateString('en-CA'); // 'en-CA' formats the date as YYYY-MM-DD + export const WIDGET_REGISTRY = { "vaa_activity": { name: "VAA 7 Day Activity", @@ -19,7 +22,9 @@ export const WIDGET_REGISTRY = { // The baseline data every new VAA widget starts with defaultSettings: { - yAxisScale: 'normal' // Options: 'normal' | 'symlog' + // startDate: getStartDate(), + // endDate: getEndDate(), + yAxisScale: 'normal', // Options: 'normal' | 'symlog' }, allowedSizes: { @@ -44,6 +49,8 @@ export const WIDGET_REGISTRY = { component: lazy(() => import('../components/Widgets/IvansAlerts.jsx')), settingsComponent: lazy(() => import('../components/Widgets/settings/IvansAlertsSettings.jsx')), defaultSettings: { + startDate: getStartDate(), + endDate: getEndDate(), copyToClipboard: false, activeSorts: { timeSort: "desc", @@ -64,6 +71,8 @@ export const WIDGET_REGISTRY = { component: lazy(() => import('../components/Widgets/RemoteSensing.jsx')), settingsComponent: lazy(() => import('../components/Widgets/Settings/RemoteSensingSettings.jsx')), defaultSettings: { + startDate: getStartDate(), + endDate: getEndDate(), copyToClipboard: false, activeSorts: { timeSort: "desc", @@ -81,9 +90,15 @@ export const WIDGET_REGISTRY = { name: "Alert Level Changes", category: "Daily Alerts", description: "List of 7-day alert level changes with trend arrows.", + component: lazy(() => import('../components/Widgets/AlertChanges.jsx')), + settingsComponent: lazy(() => import('../components/Widgets/settings/AlertChangeSettings.jsx')), + defaultSettings: { + startDate: getStartDate(), + endDate: getEndDate(), + }, allowedSizes: { - small: { w: 3, h: 12 }, - large: { w: 9, h: 12 } + small: { w: 3, h: 6 }, + large: { w: 3, h: 12 } } }, @@ -99,6 +114,8 @@ export const WIDGET_REGISTRY = { // The baseline data every new VAA widget starts with defaultSettings: { + startDate: getStartDate(), + endDate: getEndDate(), yAxisScale: 'normal' // Options: 'normal' | 'symlog' }, allowedSizes: { @@ -112,7 +129,10 @@ export const WIDGET_REGISTRY = { 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: {}, + defaultSettings: { + startDate: getStartDate(), + endDate: getEndDate(), + }, allowedSizes: { medium: { w: 6, h: 12 } } @@ -124,7 +144,10 @@ export const WIDGET_REGISTRY = { 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: {}, + defaultSettings: { + startDate: getStartDate(), + endDate: getEndDate(), + }, allowedSizes: { medium: { w: 6, h: 12 } } diff --git a/client/src/hooks/useBuildUrl.js b/client/src/hooks/useBuildUrl.js new file mode 100644 index 0000000..3e99e3b --- /dev/null +++ b/client/src/hooks/useBuildUrl.js @@ -0,0 +1,16 @@ +import { useMemo } from "react"; + +export default function useBuildUrl(base_url, start, end) { + return useMemo(() =>{ + if (start && end) + return `${base_url}?start_date=${start}&end_date=${end}`; + else { + let today = new Date(); + let sevenDaysAgo = new Date(today); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA'); + today = today.toLocaleDateString('en-CA'); + return `${base_url}?start_date=${sevenDaysAgo}&end_date=${today}`; + } + }, [base_url, start, end]); +} \ No newline at end of file