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: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'no-unused-vars': 'off',
},
}, ...storybook.configs["flat/recommended"]])
+67 -160
View File
@@ -1,22 +1,18 @@
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 {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,10 +23,11 @@ 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 = [
const apiData = useMemo(() => {
return ([
{
id: 277,
volcano_number: 268010,
@@ -90,11 +87,12 @@ export default function IvansAlerts({ settings, widgetID }) {
}
]
}
];
])
}, []);
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 (
<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 (
<Flex w="100%" h="100%" direction="column" bg="gray.800" borderRadius="xl" align="center" justify="center" gap={2} p={2} overflow="auto">
{/*<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>*/}
<Flex pos="relative" w="100%" h="100%" direction="column" bg="gray.800" borderRadius="xl" align="center" justify="center" gap={2} p={2}>
{data.map((item, index) => {
const context_string = item.volcano_name + ' • ' + item.send_time;
return (
<Grid w="100%" h="100%" templateColumns="repeat(8, 1fr)" key={index} borderRadius="lg" bg="gray.700" alignItems="center" justifyContent="center">
<GridItem colSpan={2} color="gray.100" pl={4}>{item.volcano_name}</GridItem>
<GridItem colSpan={1} color="gray.100">{item.report_num}</GridItem>
<GridItem colSpan={2} color="gray.100">{item.send_time}</GridItem>
<GridItem colSpan={1} color="gray.100">{item.preparer}</GridItem>
<GridItem colSpan={1}>
<>
<Button w="7rem" h={0} mb={1} onClick={() => setActiveSummaryItem(item)} color="purple.300" variant="plain" textDecor="underline">View Summary</Button>
/* IVANS ENTRIES */
<Flex onClick={() => setActiveSummaryItem(item)} w="100%" h="100%" key={index} borderRadius="lg" bg="gray.700" justify="space-between" align="center">
<Flex direction="column" w="60%" pl={3} color="gray.100">
<Text>{item.volcano_name}</Text>
<Text fontSize="xs" color="gray.300/90">{item.send_time}</Text>
</Flex>
<Flex direction="row" w="40%" justify="right" pr={3}>
<Text color="gray.100" bg="gray.800/50" borderRadius="lg" pl={2} pr={2}>{item.report_num}</Text>
</Flex>
</Flex>
)
})}
{/* SUMMARY/PDF VIEWER */}
<SummaryDialog
open={!!activeSummaryItem}
onOpenChange={() => setActiveSummaryItem(null)}
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>
);
}
function SummaryDialog({ open, onOpenChange, item }) {
return (
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content w="100vw" h="40vh">
<Dialog.Header>
<Dialog.Root lazyMount
open={open}
onOpenChange={onOpenChange}
trapFocus={false}
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%">
<Flex w="100%" h="100%" direction="column" textAlign="left">
<Flex direction="row" justify="space-between" align="center">
<Text fontSize="md">{item?.volcano_name}</Text>
<Text bg="gray.800" fontSize="xs" borderRadius="sm" color="gray.500" pl={2} pr={2}>Preparer: {item?.preparer}</Text>
<Flex w="100%" direction="row" justify="space-between" align="center">
<Flex w="100%" direction="column">
<Text w="100%" fontSize="lg">
{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>
<Text fontSize="xs" color="blue.400">{item?.send_time}</Text>
</Flex>
</Dialog.Title>
</Dialog.Header>
<Dialog.Body bg="gray.800" overflow="auto">
<Text textAlign="left">{item?.summary}</Text>
</Dialog.Body>
<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 }) {
<Text textAlign="left" color="gray.100">{item?.summary}</Text>
<br />
{item?.files?.map((file, index) => {
return (
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
<Dialog.Backdrop />
<Dialog.Positioner>
<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' }}>
<object data={`${file?.url}#view=FitH&toolbar=1`}
type="application/pdf"
style={{ width: '100%', height: '100%', display: 'block' }}
>
<Flex w="100%" h="100%" color="gray.400" p={4}>
<Text mb={2} color="fg.muted">Your browser cannot display this PDF directly.</Text>
<a href={item?.url} target="_blank">
<Text mb={2} color="fg.muted">
Your browser cannot display this PDF directly.
</Text>
<a href={file?.url} target="_blank">
Download PDF
</a>
</Flex>
</object>
</Flex>
)})}
</Dialog.Body>
<Dialog.Footer>
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
<Dialog.Footer p={2} pr={3}>
<Button size="xs" variant="subtle" onClick={() => onOpenChange({ open: false })}>
Close
</Button>
</Dialog.Footer>
<Dialog.CloseTrigger />
</Dialog.Content>
</Dialog.Positioner>
</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) {
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: {
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",
+1 -1
View File
@@ -122,7 +122,7 @@ export default function MyDashboard() {
<Box flex="1" overflow="hidden" position="relative" p={1}>
{/* 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 */}
<Component settings={widget.settings || {}} widgetID={widget.widgetID || {}}/>
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
</Box>
</Flex>