Adding functioning plot of Plume Events widget

This commit is contained in:
2026-08-05 17:16:45 +00:00
parent 8525dcc71c
commit 999e3bb50c
6 changed files with 292 additions and 232 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -24,6 +24,6 @@ export default defineConfig([globalIgnores(['dist']), {
}, },
}, },
rules: { rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 'no-unused-vars': 'off',
}, },
}, ...storybook.configs["flat/recommended"]]) }, ...storybook.configs["flat/recommended"]])
+67 -160
View File
@@ -1,22 +1,18 @@
import VdapApi from '../../hooks/useVdapApi.js'; import VdapApi from '../../hooks/useVdapApi.js';
import {Button, Dialog, Flex, Grid, GridItem, Spinner, Text} from "@chakra-ui/react"; import {Button, Dialog, Flex, Portal, Spinner, Text} from "@chakra-ui/react";
import {useEffect, useMemo, useState } from 'react'; import {useEffect, useMemo, useState } from 'react';
import {useDashboardStore} from "@/store/useDashboardStore.jsx"; import {useDashboardStore} from "@/store/useDashboardStore.jsx";
export default function IvansAlerts({ settings, widgetID }) { export default function IvansAlerts({ settings, widgetID }) {
console.log("IvansAlerts"); console.log("IvansAlerts widget ID: ", widgetID);
const [activeSummaryItem, setActiveSummaryItem] = useState(false); 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 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; const sorts = settings?.activeSorts;
// build url once on mount // build url once on mount
const seven_day_url = useMemo(() => { 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 today = new Date();
let sevenDaysAgo = new Date(today); let sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
@@ -27,10 +23,11 @@ export default function IvansAlerts({ settings, widgetID }) {
return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; 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 {data: apiD, isLoading, error} = VdapApi(seven_day_url);
const apiData = [ const apiData = useMemo(() => {
return ([
{ {
id: 277, id: 277,
volcano_number: 268010, volcano_number: 268010,
@@ -90,11 +87,12 @@ export default function IvansAlerts({ settings, widgetID }) {
} }
] ]
} }
]; ])
}, []);
const data = useMemo(() => { const data = useMemo(() => {
if (apiData.length === 0) return []; if (apiData?.length === 0) return [];
let sorted = []; let sorted;
if (sorts?.volcanoSort && sorts?.timeSort === "asc") { if (sorts?.volcanoSort && sorts?.timeSort === "asc") {
sorted = [...apiData].sort(ascTimeVolcanoSort); sorted = [...apiData].sort(ascTimeVolcanoSort);
@@ -106,13 +104,13 @@ export default function IvansAlerts({ settings, widgetID }) {
sorted = [...apiData].sort(descTimeSort); sorted = [...apiData].sort(descTimeSort);
} }
console.log("sorted data: ", sorted);
return sorted; return sorted;
}, [apiData, sorts]); }, [apiData, sorts]);
useEffect(() => { useEffect(() => {
if(isCopy) { 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(() => { .then(() => {
alert('Copied to clipboard!'); alert('Copied to clipboard!');
}) })
@@ -121,7 +119,7 @@ export default function IvansAlerts({ settings, widgetID }) {
})); }));
updateSettings(widgetID, {copyToClipboard: false}); updateSettings(widgetID, {copyToClipboard: false});
} }
}, [isCopy]); }, [isCopy, data, widgetID, updateSettings]);
if (isLoading) return ( if (isLoading) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center"> <Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
@@ -142,187 +140,96 @@ export default function IvansAlerts({ settings, widgetID }) {
); );
return ( return (
<Flex w="100%" h="100%" direction="column" bg="gray.800" borderRadius="xl" align="center" justify="center" gap={2} p={2} overflow="auto"> <Flex pos="relative" w="100%" h="100%" direction="column" bg="gray.800" borderRadius="xl" align="center" justify="center" gap={2} p={2}>
{/*<Grid w="100%" h="100%" templateColumns="repeat(6, 1fr)" alignItems="center">*/}
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" pl={4}>Volcano</GridItem>*/}
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold">Report Number</GridItem>*/}
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" pl={3}>Report Send Time</GridItem>*/}
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Preparer</GridItem>*/}
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Summary</GridItem>*/}
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Attachments</GridItem>*/}
{/*</Grid>*/}
{data.map((item, index) => { {data.map((item, index) => {
const context_string = item.volcano_name + ' • ' + item.send_time;
return ( return (
<Grid w="100%" h="100%" templateColumns="repeat(8, 1fr)" key={index} borderRadius="lg" bg="gray.700" alignItems="center" justifyContent="center"> /* IVANS ENTRIES */
<GridItem colSpan={2} color="gray.100" pl={4}>{item.volcano_name}</GridItem> <Flex onClick={() => setActiveSummaryItem(item)} w="100%" h="100%" key={index} borderRadius="lg" bg="gray.700" justify="space-between" align="center">
<GridItem colSpan={1} color="gray.100">{item.report_num}</GridItem> <Flex direction="column" w="60%" pl={3} color="gray.100">
<GridItem colSpan={2} color="gray.100">{item.send_time}</GridItem> <Text>{item.volcano_name}</Text>
<GridItem colSpan={1} color="gray.100">{item.preparer}</GridItem> <Text fontSize="xs" color="gray.300/90">{item.send_time}</Text>
<GridItem colSpan={1}> </Flex>
<> <Flex direction="row" w="40%" justify="right" pr={3}>
<Button w="7rem" h={0} mb={1} onClick={() => setActiveSummaryItem(item)} color="purple.300" variant="plain" textDecor="underline">View Summary</Button> <Text color="gray.100" bg="gray.800/50" borderRadius="lg" pl={2} pr={2}>{item.report_num}</Text>
</Flex>
</Flex>
)
})}
{/* SUMMARY/PDF VIEWER */}
<SummaryDialog <SummaryDialog
open={!!activeSummaryItem} open={!!activeSummaryItem}
onOpenChange={() => setActiveSummaryItem(null)} onOpenChange={() => setActiveSummaryItem(null)}
item={activeSummaryItem} item={activeSummaryItem}
context={context_string}
/> />
</>
</GridItem>
<GridItem colSpan={1}>
<Flex direction="column" justify="center" alignItems="center" gap={0}>
{item.files.length === 0 ? (
<Text color="gray.600" fontSize="sm">No Attachments</Text>
):(
item.files.map((file, index) => {
if (file.type === "pdf") return (
<div key={index}>
<Button w="7rem" h={0} mb={1} onClick={() => setActiveFileItem(file)} color="purple.300" variant="plain" textDecor="underline">View PDF</Button>
<FileDialog
open={!!activeFileItem}
onOpenChange={() => setActiveFileItem(null)}
item={activeFileItem}
context={context_string}
/>
</div>
)
else if (file.type === "image") return (
<div key={index}>
<Button w="7rem" onClick={() => setActiveImageItem(file)} color="purple.300" variant="plain" textDecor="underline">View Image</Button>
<ImageDialog
open={!!activeImageItem}
onOpenChange={() => setActiveImageItem(null)}
item={activeImageItem}
context={context_string}
/>
</div>
)
else if (file.type === "download") return (
<Button key={index} w="7rem" onClick={() => downloadReport(file.url, file.name) } color="purple.300" variant="plain" textDecor="underline">Download</Button>
)
})
)}
</Flex>
</GridItem>
</Grid>
)
})}
</Flex> </Flex>
); );
} }
function SummaryDialog({ open, onOpenChange, item }) { function SummaryDialog({ open, onOpenChange, item }) {
return ( return (
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}> <Dialog.Root lazyMount
<Dialog.Backdrop /> open={open}
<Dialog.Positioner> onOpenChange={onOpenChange}
<Dialog.Content w="100vw" h="40vh"> trapFocus={false}
<Dialog.Header> modal={false}
closeOnInteractOutside={false}
motionPreset="slide-in-bottom"
position="absolute"
>
<Dialog.Positioner w="100%" h="100%">
<Dialog.Content w="100%" h="100%" maxW="none" p={0} m={0} borderRadius="xl" boxShadow="none">
<Dialog.Header p={3}>
<Dialog.Title w="100%"> <Dialog.Title w="100%">
<Flex w="100%" h="100%" direction="column" textAlign="left"> <Flex w="100%" direction="row" justify="space-between" align="center">
<Flex direction="row" justify="space-between" align="center"> <Flex w="100%" direction="column">
<Text fontSize="md">{item?.volcano_name}</Text> <Text w="100%" fontSize="lg">
<Text bg="gray.800" fontSize="xs" borderRadius="sm" color="gray.500" pl={2} pr={2}>Preparer: {item?.preparer}</Text> {item?.volcano_name}
</Text>
<Text w="100%" fontSize="sm" color="blue.400">
{item?.send_time}
</Text>
</Flex>
<Flex w="100%" justify="right" align="center">
<Text textAlign="center" bg="gray.600" fontSize="xs" borderRadius="xl" color="gray.400" pl={2} pr={2}>
prepared by: {item?.preparer}
</Text>
</Flex> </Flex>
<Text fontSize="xs" color="blue.400">{item?.send_time}</Text>
</Flex> </Flex>
</Dialog.Title> </Dialog.Title>
</Dialog.Header> </Dialog.Header>
<Dialog.Body bg="gray.800" overflow="auto"> <Dialog.Body bg="gray.800" overflow="auto">
<Text textAlign="left">{item?.summary}</Text> <Text textAlign="left" color="gray.100">{item?.summary}</Text>
</Dialog.Body> <br />
{item?.files?.map((file, index) => {
<Dialog.Footer>
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
Close
</Button>
</Dialog.Footer>
<Dialog.CloseTrigger />
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
)
}
function FileDialog({ open, onOpenChange, item, context }) {
return ( return (
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}> <object data={`${file?.url}#view=FitH&toolbar=1`}
<Dialog.Backdrop /> type="application/pdf"
<Dialog.Positioner> style={{ width: '100%', height: '100%', display: 'block' }}
<Dialog.Content w="100vw" h="75vh"> >
<Dialog.Header>
<Flex height="100%" width="100%" direction="row" align="center" justifyContent="space-between">
<Flex direction="column" textAlign="left">
<Dialog.Title>{item?.name ? item?.name : 'PDF Attachment'}</Dialog.Title>
<Text color="gray.200">{context}</Text>
</Flex>
<Text bg="gray.800" p="1" borderRadius="sm">{(item?.type || '').toUpperCase()}</Text>
</Flex>
</Dialog.Header>
<Dialog.Body p={0}>
<Flex bg="gray.800" w="100%" h="100%" alignItems="center" justifyContent="center">
<object data={`${item?.url}#view=FitH`} type="application/pdf" style={{ width: '100%', height: '100%', display: 'block' }}>
<Flex w="100%" h="100%" color="gray.400" p={4}> <Flex w="100%" h="100%" color="gray.400" p={4}>
<Text mb={2} color="fg.muted">Your browser cannot display this PDF directly.</Text> <Text mb={2} color="fg.muted">
<a href={item?.url} target="_blank"> Your browser cannot display this PDF directly.
</Text>
<a href={file?.url} target="_blank">
Download PDF Download PDF
</a> </a>
</Flex> </Flex>
</object> </object>
</Flex> )})}
</Dialog.Body> </Dialog.Body>
<Dialog.Footer> <Dialog.Footer p={2} pr={3}>
<Button variant="outline" onClick={() => onOpenChange({ open: false })}> <Button size="xs" variant="subtle" onClick={() => onOpenChange({ open: false })}>
Close Close
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
<Dialog.CloseTrigger />
</Dialog.Content> </Dialog.Content>
</Dialog.Positioner> </Dialog.Positioner>
</Dialog.Root> </Dialog.Root>
) )
} }
function ImageDialog({ open, onOpenChange, item, context }) {
return (
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content w="100vw" h="40vh">
<Dialog.Header>
<Dialog.Title>{item?.name}</Dialog.Title>
<Text color="gray.200">{context}</Text>
</Dialog.Header>
<Dialog.Body bg="gray.800" overflow="auto">
<img src={item?.url} alt={item?.name} />
</Dialog.Body>
<Dialog.Footer>
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
Close
</Button>
</Dialog.Footer>
<Dialog.CloseTrigger />
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
)
}
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) { function ascTimeVolcanoSort(a, b) {
if (a.volcano_name < b.volcano_name) return -1 if (a.volcano_name < b.volcano_name) return -1
else if (a.volcano_name > b.volcano_name) return 1 else if (a.volcano_name > b.volcano_name) return 1
@@ -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:
`<b>${volcanoName}</b><br>` +
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
'Plume Height (m): %{y}<extra></extra>'
}));
}, [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<br>%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 (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" />
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text>
</Flex>
);
if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
<Text color="gray.400">No data available for current filter.</Text>
</Flex>
);
return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
<Plot
data={plotTraces}
layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing!
style={{ width: '100%', height: '100%' }}
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
/>
</Box>
);
}
+16 -1
View File
@@ -51,7 +51,11 @@ export const WIDGET_REGISTRY = {
} }
}, },
allowedSizes: { 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": { "llm_al_changes_quad_plot": {
name: "Alert Level Changes Quad Plot", name: "Alert Level Changes Quad Plot",
category: "Computer Science", category: "Computer Science",
+1 -1
View File
@@ -122,7 +122,7 @@ export default function MyDashboard() {
<Box flex="1" overflow="hidden" position="relative" p={1}> <Box flex="1" overflow="hidden" position="relative" p={1}>
{/* Pass the settings object into the actual chart component */} {/* 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 */} {/* Pass widget ID to allow updating settings in widget component rather than just in settings component */}
<Component settings={widget.settings || {}} widgetID={widget.widgetID || {}}/> <Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
</Box> </Box>
</Flex> </Flex>