Adding functioning plot of Plume Events widget
This commit is contained in:
@@ -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 (
|
||||
<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>
|
||||
<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>
|
||||
/* 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}
|
||||
/>
|
||||
</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>
|
||||
<Text textAlign="left" color="gray.100">{item?.summary}</Text>
|
||||
<br />
|
||||
{item?.files?.map((file, index) => {
|
||||
return (
|
||||
<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={file?.url} target="_blank">
|
||||
Download PDF
|
||||
</a>
|
||||
</Flex>
|
||||
</object>
|
||||
)})}
|
||||
</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 FileDialog({ open, onOpenChange, item, context }) {
|
||||
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' }}>
|
||||
<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">
|
||||
Download PDF
|
||||
</a>
|
||||
</Flex>
|
||||
</object>
|
||||
</Flex>
|
||||
</Dialog.Body>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user