Author SHA1 Message Date
vkuchenik 7b3e7d3d0c refactor: continueing to propogate changes made to widget setting popover in al changes to ivans widget 2026-11-06 10:53:49 -08:00
vkuchenik 58c6a40b06 refactor: adjusting colors in themes file for better visibility/distinction of entries in widgets like al changes, ivans, and rs in both lt and dark mode 2026-11-06 10:52:58 -08:00
vkuchenik e50638ab55 add: volcano drop down filter w/ multiselect capabilities in widget settings. UI only! No functionality has been built yet 2026-11-05 15:39:27 -08:00
vkuchenik 189d6e2d1d add: new filtering options were added to the alert changes widget. options are lvl inc, lvl dec, elevated lvls. additionally, updated how the UI looks for the al changes widget settings menu 2026-11-05 10:43:05 -08:00
vkuchenik d4fb170e14 fix:
- alert level changes now display accurate color.
- comm coor report no longer has dark loading screen in lt mode. login input text no longer white in dark mode
2026-11-03 13:27:47 -08:00
vkuchenik 872550c2c9 wip: small random ui changes 2026-10-23 11:23:16 -07:00
chight 0c178baa01 Refactor changed widgets to match themes. 2026-10-20 16:19:45 -07:00
chight 0ec4f27640 Refactor CSS for scatter plots 2026-10-20 14:59:03 -07:00
chight c027a30b3f Refactor widgets to use theme better. Alert change and 7day vaa 2026-10-20 14:05:52 -07:00
chight bd5e02ac1b Refactor connecting more of theme.js 2026-10-19 14:59:01 -07:00
chight c810056d67 Refactor dashboard to use new theme.js 2026-10-19 14:34:40 -07:00
chight 56ae6d2e3f Refactor theme.js to split components out more. 2026-10-19 14:24:43 -07:00
chight f43cfb649e Bug Fix copy paste errors repaired 2026-10-19 14:06:34 -07:00
chight 91af70dbb5 Refactor lots of changes to normaliz theme.js a bit more 2026-10-19 13:44:59 -07:00
chight 64c78cea70 Refactor starting to update theme.js 2026-10-19 10:43:28 -07:00
vkuchenik bc9fbaae6e wip - committing to check out another branch 2026-10-05 15:21:51 -07:00
19 changed files with 1328 additions and 1012 deletions
-23
View File
@@ -1,23 +0,0 @@
# Stage 1: The Node Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy React source code and build it
COPY . .
RUN npm run build
# Stage 2: The Nginx Server
FROM nginx:alpine
# Copy the compiled files from Stage 1 (Change /dist to /build if using Create-React-App instead of Vite)
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy your custom Nginx routing file
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-23
View File
@@ -1,23 +0,0 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# SPA routing: if a file isn't found, serve index.html
try_files $uri $uri/ /index.html;
}
# Optional: Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, no-transform";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+366 -146
View File
@@ -1,28 +1,51 @@
import {useDashboardStore} from "@/store/useDashboardStore.jsx"; import { useDashboardStore } from "@/store/useDashboardStore.jsx";
import VdapApi from "../../hooks/useVdapApi.js"; import VdapApi from "../../hooks/useVdapApi.js";
import useBuildUrl from "../../hooks/useBuildUrl.js"; import useBuildUrl from "../../hooks/useBuildUrl.js";
import { Footprints, Plane, Sailboat, TrendingUp, TrendingDown } from 'lucide-react'; import {
import {Box, Collapsible, Flex, Spinner, Text, Timeline, Center} from "@chakra-ui/react"; 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"; import { useMemo, useCallback } from "react";
export default function AlertChanges({ settings, widgetID }) {
// 0) CREATE SELF AWARENESS, by finding 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],
),
);
export default function AlertChanges({ settings, widgetID}) { // 1) BUILD URL, default is 7 days past
// Find this specific widgets object in the state list of active & displayed widgets const url = useBuildUrl(
const thisWidget = useDashboardStore(useCallback((state) => "/api/v2/alert_changes",
state.widgets.find((widget) => widget.id === widgetID), [widgetID] settings?.startDate,
)); settings?.endDate,
);
// build url, default is 7 days past // 2) CALL API hook to retrieve data
const url = useBuildUrl('/api/v2/alert_changes', settings?.startDate, settings?.endDate); const { data: apiData, isLoading, error } = VdapApi(url);
// call api hook to retrieve data // 3) SORT the data by grouping into volcanoes and sorting form newest to oldest
const {data: apiData, isLoading, error} = VdapApi(url); // doing this 1st bc the filters might change w/ user input but the sorts (currently) wont and thus will
// only run when the api retrieves new data
const data = useMemo(() => { const sortedData = useMemo(() => {
if (apiData.length === 0) return []; 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) // .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 // here we sort the data by volcano, and return it as a single massive object w/ volcano names as keys
const grouped = apiData.reduce((accumulator, item) => { const grouped = apiData.reduce((accumulator, item) => {
if (!accumulator[item.volcano]) accumulator[item.volcano] = []; if (!accumulator[item.volcano]) accumulator[item.volcano] = [];
accumulator[item.volcano].push(item); accumulator[item.volcano].push(item);
@@ -33,187 +56,384 @@ export default function AlertChanges({ settings, widgetID}) {
const group_arr = Object.entries(grouped); const group_arr = Object.entries(grouped);
// sort data newest to oldest within each volcano // sort data newest to oldest within each volcano
const sorted = group_arr.map((item) => const sorted = group_arr.map((item) => [
[ item[0], // volcano name
item[0], // volcano name [...item[1]].sort( // item[1] is the array of al changes for a volcano
[...(item[1])].sort((a, b) => (a, b) =>
new Date(b.alert_start_date_local) - new Date(a.alert_start_date_local)) new Date(b.alert_start_date_local) -
] new Date(a.alert_start_date_local),
),
]);
// the volcanoes with the most recent issued alert level change appear at the top of the list (newest -> oldest)
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),
); );
// volcano with most recent alert level change appears at top of list // additionally, seperates out data into domains within a volcano for sm widgets,
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)); // to list all most recent al changes w/in a volcano w/in a domain
if (thisWidget?.size === "small") { if (thisWidget?.size === "small") {
return (sorted_entries.map((volcano) => { return sorted_entries.map((volcano) => {
const domain = volcano[1].reduce((acc, curr) => { const domain = volcano[1].reduce((acc, curr) => {
if (!acc[curr.alert_scheme_domain]) acc[curr.alert_scheme_domain] = []; if (!acc[curr.alert_scheme_domain])
acc[curr.alert_scheme_domain] = [];
acc[curr.alert_scheme_domain].push(curr); acc[curr.alert_scheme_domain].push(curr);
return acc; return acc;
}, {}); }, {});
const domain_arr = Object.entries(domain); const domain_arr = Object.entries(domain);
console.log("sm data: ", [volcano[0], domain_arr]);
return [volcano[0], domain_arr]; return [volcano[0], domain_arr];
})); });
} }
console.log("lg data: ", sorted_entries);
return sorted_entries; return sorted_entries;
}, [apiData, thisWidget]); }, [apiData, thisWidget]);
// list of alert colors used for coloring text below // 4) FILTER according to what is set in the widgets settings
const filteredData = useMemo(() => {
if (sortedData.length === 0) return [];
let filtered_arr = [];
// must show at least one inc or dec alert levels, otherwise no data is displayed
if (
!settings?.filters?.levelIncreases &&
!settings?.filters?.levelDecreases
)
return [];
// return all data
if (
settings?.filters?.levelIncreases &&
settings?.filters?.levelDecreases &&
!settings?.filters.elevatedLevels
)
return sortedData;
// only elevated alert levels
filtered_arr = settings?.filters.elevatedLevels
? sortedData.map((volcano) => [
volcano[0],
volcano[1].filter(
(item) =>
item.alert_scheme_highest - Number(item.alert_level_rank) <= 1,
),
])
.filter((volcano) => volcano[1].length > 0)
: sortedData;
// only decreases (rm inc)
filtered_arr = !settings?.filters.levelIncreases
? filtered_arr.map((volcano) => [
volcano[0],
volcano[1].filter((item) => !(item.change_type === "increase")),
])
.filter((volcano) => volcano[1].length > 0)
: filtered_arr;
// only increases (rm dec)
filtered_arr = !settings?.filters?.levelDecreases
? filtered_arr.map((volcano) => [
volcano[0],
volcano[1].filter((item) => !(item.change_type === "decrease")),
])
.filter((volcano) => volcano[1].length > 0)
: filtered_arr;
console.log("filtered_arr: ", filtered_arr);
return filtered_arr;
}, [sortedData, settings]);
// Alert colors are now light/dark mode responsive
const alertColors = { const alertColors = {
Red: "red.400", Red: "alertRed",
Orange: "orange.400", Orange: "alertOrange",
Yellow: "yellow.400", Yellow: "alertYellow",
Green: "green.400", Green: "alertGreen",
}; };
if (isLoading) return ( const options = {
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> year: "numeric",
<Spinner size="xl" color="blue.400" thickness="4px"/> month: "short",
</Flex> day: "numeric",
); hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short"
};
if (error) return ( if (isLoading)
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> return (
<Text color="red.400">{error}</Text> <Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
</Flex> <Spinner size="xl" color="blue.400" thickness="4px" />
); </Flex>
);
if (data.length === 0) return ( if (error)
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> return (
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text> <Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
</Flex> <Text color="red.400">{error}</Text>
); </Flex>
);
if (filteredData.length === 0)
return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Text textAlign="center" color="widgetTextMuted">
No alert level data available for the selected dates.
</Text>
</Flex>
);
return ( return (
<Box display="flex" flexDirection="column" gap={2} p={2} w="100%" h="100%" borderRadius="lg" bg="gray.800" overflowY="auto"> <Box
{data.map((volcano, index) => { display="flex"
if (thisWidget?.size === "large") return ( flexDirection="column"
<Collapsible.Root key={index} w="100%"> gap={2}
{/* Using context to dynamically change the border radii of the triggers */} p={2}
<Collapsible.Context> w="100%"
{(collapsible) => ( h="100%"
<> bg="transparent"
overflowY="auto"
>
{filteredData.map((volcano, index) => {
if (thisWidget?.size === "large")
return (
<Collapsible.Root key={index} w="100%">
<Collapsible.Context>
{(collapsible) => (
<Collapsible.Trigger w="100%"> <Collapsible.Trigger w="100%">
<Flex direction="row" <Flex
w="100%" direction="row"
justify="space-between" align="center" w="100%"
p={1} pl={3} pr={3} justify="space-between"
borderTopRadius="lg" align="center"
borderBottomRadius={collapsible.open ? "0" : "lg"} p={1}
bg="gray.600" pl={3}
pr={3}
borderTopRadius="lg"
borderBottomRadius={collapsible.open ? "0" : "lg"}
bg="widgetItemBg"
> >
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100"> <Flex
<Text truncate >{volcano[0]}</Text> {/* volcano name */} direction="column"
<Text truncate fontSize="xs" color="gray.300/90">{volcano[1][0]?.alert_start_date_local}</Text> w="100%"
minW={0}
textAlign="left"
>
<Text truncate color="text" fontWeight="medium">
{volcano[0]}
</Text>
<Text truncate fontSize="xs" color="widgetTextMuted">
{new Date(volcano[1][0]?.alert_start_date_local)
.toLocaleString('en-US', options)
}
</Text>
</Flex> </Flex>
<Flex w="100%" minW={0} justify="right"> <Flex w="100%" minW={0} justify="right" gap={2}>
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{volcano[1][0]?.alert_level_name}</Text> <Flex w="fit" color="iconColor">
{volcano[1][0]?.change_type === "increase" ? (
<TrendingUp />
) : (
<TrendingDown />
)}
</Flex>
<Text
truncate
pl={2}
pr={2}
borderRadius="lg"
bg="widgetBadgeBg"
color="text"
fontWeight="semibold"
>
{volcano[1][0]?.alert_level_name}
</Text>
</Flex> </Flex>
</Flex> </Flex>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content w="100%" p={3} borderBottomRadius="lg" bg="gray.700"> )}
<Timeline.Root size="md" variant="subtle"> </Collapsible.Context>
{volcano[1]?.map((object, index) => (
<Timeline.Item key={index} w="100%" h="100%"> <Collapsible.Content
<Timeline.Connector > w="100%"
{/*<Timeline.Separator borderWidth="1px" borderColor="gray.600"/>*/} p={3}
<Timeline.Indicator outline="none" bg="gray.200" color="gray.500" size="lg"> borderBottomRadius="lg"
{object?.alert_scheme_domain === "terrestrial" && <Footprints size={15} />} bg="widgetItemExpandedBg"
{object?.alert_scheme_domain === "aviation" && <Plane size={15} />} >
{object?.alert_scheme_domain === "maritime" && <Sailboat size={15} />} <Timeline.Root size="md" variant="subtle">
</Timeline.Indicator> {volcano[1]?.map((object, index) => (
<Timeline.Indicator h="50%" outline="none" bg="gray.700" color="gray.100" size="lg"> <Timeline.Item key={index} w="100%" h="100%">
{object?.change_type === "increase" ? <TrendingUp /> : <TrendingDown />} <Timeline.Connector>
</Timeline.Indicator> <Timeline.Indicator
</Timeline.Connector> outline="none"
<Timeline.Content w="100%" h="100%"> bg="transparent"
{/*<Timeline.Title>{object?.volcano}</Timeline.Title>*/} color="iconColor"
<Timeline.Description w="100%" h="100%" gap={1}> size="lg"
<Flex direction="column" gap={1}> >
<Text w="100%" h="100%" color="gray.200">{object?.alert_start_date_local}</Text> {object?.alert_scheme_domain === "terrestrial" && <Footprints size={15} />}
<Text w="100%" h="100%" {object?.alert_scheme_domain === "aviation" && <Plane size={15} />}
color={alertColors[object?.alert_level_name] || "gray.200"} {object?.alert_scheme_domain === "maritime" && <Sailboat size={15} />}
fontSize="md" </Timeline.Indicator>
fontWeight="semibold" <Timeline.Indicator
> h="50%"
{object?.alert_level_name} outline="none"
</Text> bg="transparent"
<Flex w="100%" h="20px" direction="row" justify="left" align="center"> color="iconColor"
<Text color="gray.300">Alert Scale: </Text> size="lg"
{(Array.from({ length: Number(object?.alert_scheme_highest) - Number(object?.alert_scheme_lowest) + 1}, (v, i) => Number(object?.alert_scheme_lowest) + i)) >
.map((num) => ( {object?.change_type === "increase" ? (
<Center <TrendingUp />
key={num} ) : (
w="20px" h="100%" <TrendingDown />
color="gray.300" )}
bg={num === Number(object?.alert_level_rank) ? "gray.300/20" : null} // alert level rank is a string for some reason </Timeline.Indicator>
borderRadius={num === Number(object?.alert_level_rank) ? "xl" : null} </Timeline.Connector>
textAlign="center" <Timeline.Content w="100%" h="100%">
fontSize="sm" <Timeline.Description w="100%" h="100%" gap={1}>
fontWeight="semibold" <Flex direction="column" gap={1}>
> <Text
{num} w="100%"
</Center> h="100%"
)) color="widgetTextMuted"
>
{new Date(object?.alert_start_date_local)
.toLocaleString('en-US', {...options, timeZone: 'UTC'})
}
</Text>
<Text
w="100%"
h="100%"
color={
alertColors[object?.alert_level_name] || "text"
}
fontSize="md"
fontWeight="semibold"
>
{object?.alert_level_name}
</Text>
<Flex
w="100%"
h="20px"
direction="row"
justify="left"
align="center"
>
<Text color="widgetTextMuted" mr={1}>
Alert Scale:
</Text>
{Array.from(
{
length:
Number(object?.alert_scheme_highest) -
Number(object?.alert_scheme_lowest) + 1,
},
(v, i) => Number(object?.alert_scheme_lowest) + i
).map((num) => (
<Center
key={num}
w="20px"
h="100%"
color="text"
bg={
num === Number(object?.alert_level_rank)
? "widgetInnerBadgeBg"
: "transparent"
} }
</Flex> borderRadius={
</Flex> num === Number(object?.alert_level_rank)
</Timeline.Description> ? "xl"
</Timeline.Content> : "none"
</Timeline.Item> }
))} textAlign="center"
</Timeline.Root> fontSize="sm"
</Collapsible.Content> fontWeight="bold"
</> >
)} {num}
</Collapsible.Context> </Center>
</Collapsible.Root> ))}
) </Flex>
else if (thisWidget?.size === "small") return ( </Flex>
volcano[1]?.map((domain, index) => ( </Timeline.Description>
</Timeline.Content>
</Timeline.Item>
))}
</Timeline.Root>
</Collapsible.Content>
</Collapsible.Root>
);
else if (thisWidget?.size === "small")
return volcano[1]?.map((domain, index) => (
<Flex <Flex
key={index} key={index}
direction="row" direction="row"
w="100%" w="100%"
gap={2} gap={2}
justify="space-between" align="center" justify="space-between"
p={1} pl={3} pr={3} align="center"
p={1}
pl={3}
pr={3}
borderRadius="lg" borderRadius="lg"
bg="gray.600" bg="widgetItemBg"
> >
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100"> <Flex
direction="column"
w="100%"
minW={0}
textAlign="left"
color="text"
>
<Flex direction="row" w="100%" gap={2} align="center"> <Flex direction="row" w="100%" gap={2} align="center">
<Flex color="gray.300"> <Flex color="iconColor">
{domain[0] === "aviation" && <Plane size={15}/>} {domain[0] === "aviation" && <Plane size={15} />}
{domain[0] === "terrestrial" && <Footprints size={15}/>} {domain[0] === "terrestrial" && <Footprints size={15} />}
{domain[0] === "maritime" && <Sailboat size={15}/>} {domain[0] === "maritime" && <Sailboat size={15} />}
</Flex> </Flex>
<Text truncate>{volcano[0] || ""}</Text> {/* volcano name */} <Text truncate fontWeight="medium">
{volcano[0] || ""}
</Text>
</Flex> </Flex>
<Text truncate w="100%" fontSize="xs" color="gray.300/90">{domain[1]?.[0]?.alert_start_date_local || ""}</Text> <Text truncate w="100%" fontSize="xs" color="widgetTextMuted">
{domain[1]?.[0]?.alert_start_date_local || ""}
</Text>
</Flex> </Flex>
<Flex direction="row" w="100%" minW={0} justify="right" gap={2} color="gray.200"> <Flex
<Flex w="25px" justify="right"> direction="row"
{domain[1]?.[0]?.change_type === "increase" ? <TrendingUp/> : <TrendingDown/>} w="100%"
minW={0}
justify="right"
gap={2}
color="text"
>
<Flex w="25px" justify="right" color="iconColor">
{domain[1]?.[0]?.change_type === "increase" ? (
<TrendingUp />
) : (
<TrendingDown />
)}
</Flex> </Flex>
<Text <Text
truncate truncate
pl={2} pr={2} pl={2}
pr={2}
borderRadius="lg" borderRadius="lg"
bg="gray.800/50" bg="widgetBadgeBg"
color={alertColors[domain[1]?.[0]?.alert_level_name] || "gray.100"} color={
alertColors[domain[1]?.[0]?.alert_level_name] || "text"
}
fontWeight="semibold" fontWeight="semibold"
> >
{domain[1]?.[0]?.alert_level_name} {domain[1]?.[0]?.alert_level_name}
</Text> </Text>
</Flex> </Flex>
</Flex> </Flex>
)) ));
);
})} })}
</Box> </Box>
) );
} }
@@ -115,19 +115,19 @@ export default function CommCoorReport({ settings, widgetID }) {
}, [settings, widgetID, updateSettings, apiData]); }, [settings, widgetID, updateSettings, apiData]);
if (isLoading) return ( if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px"/> <Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex> </Flex>
); );
if (error) return ( if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" textAlign="center" borderRadius="lg" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" textAlign="center" borderRadius="lg" bg="transparent">
<Text color="red.400">{error}</Text> <Text color="red.400">{error}</Text>
</Flex> </Flex>
); );
if (!apiData) return ( if (!apiData) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Text textAlign="center" color="gray.400">No data</Text> <Text textAlign="center" color="gray.400">No data</Text>
</Flex> </Flex>
); );
@@ -5,13 +5,17 @@ import Plot from 'react-plotly.js';
import VdapApi from '../../hooks/useVdapApi.js'; import VdapApi from '../../hooks/useVdapApi.js';
import useBuildUrl from "@/hooks/useBuildUrl.js"; import useBuildUrl from "@/hooks/useBuildUrl.js";
// 1. Import your central color dictionary
import { rawWidgetColors } from '../../theme.js';
export default function ExplosionEvents({ settings, widgetID }) { export default function ExplosionEvents({ settings, widgetID }) {
// Plotly expects 'linear' for normal axes // Plotly expects 'linear' for normal axes
const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear'; const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear';
// 🎨 Let's check color mode so we can make people happy with light and dark // 🎨 Let's check color mode so we can make people happy with light and dark
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const isDark = colorMode === 'dark'; // Assign the active palette based on the current mode
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
// Generate url with input dates, default to 7 day range // Generate url with input dates, default to 7 day range
const url = useBuildUrl('/api/v2/explosion_events', settings?.startDate, settings?.endDate); const url = useBuildUrl('/api/v2/explosion_events', settings?.startDate, settings?.endDate);
@@ -53,58 +57,46 @@ export default function ExplosionEvents({ settings, widgetID }) {
mode: 'markers', mode: 'markers',
marker: { marker: {
size: 7, size: 7,
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' }, line: { width: .5, color: plotColors.text },
symbol: Symbolway[index % Symbolway.length], symbol: Symbolway[index % Symbolway.length],
}, },
// Replicating the clean D3/Observable tooltip // Replicating the clean D3/Observable tooltip
hovertemplate: hovertemplate:
`<b>${volcanoName}</b><br>` + `<b>${volcanoName}</b><br>` +
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' + 'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
'Number of Explosions: %{y}<extra></extra>' 'Number of Explosions: %{y}<extra></extra>'
})); }));
}, [apiData]); }, [apiData, plotColors]);
// 2. Build the Layout (Styling and Grid) // 2. Build the Layout (Styling and Grid)
const layout = useMemo(() => { const layout = useMemo(() => {
return { return {
autosize: true, autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent', plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300 font: { color: plotColors.muted },
margin: { t: 40, r: 20, l: 20, b: 20 }, 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: { xaxis: {
title: { title: {
text: 'Date (UTC)', text: 'Date (UTC)',
font: { color: 'white', size: 12 } font: { color: plotColors.text, size: 12 }
}, },
type: 'date', type: 'date',
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting tickformat: '%b %d<br>%Y',
gridcolor: '#4b5563', gridcolor: plotColors.grid,
zerolinecolor: '#4b5563', zerolinecolor: plotColors.grid,
automargin: true, automargin: true,
}, },
yaxis: { yaxis: {
title: { title: {
text: 'Number of Explosions', text: 'Number of Explosions',
font: { color: 'white', size: 12 } font: { color: plotColors.text, size: 12 }
}, },
type: plotYAxisType, type: plotYAxisType,
gridcolor: '#4b5563', gridcolor: plotColors.grid,
zerolinecolor: '#ffffff', zerolinecolor: plotColors.text,
automargin: true, automargin: true,
}, },
// legend: {
// orientation: 'h',
// y: -0.2,
// x: 0.5,
// xanchor: 'center'
// },
hovermode: 'closest', hovermode: 'closest',
shapes: [ shapes: [
{ {
@@ -115,40 +107,40 @@ export default function ExplosionEvents({ settings, widgetID }) {
y0: 0, y0: 0,
y1: 0, y1: 0,
yref: 'y', yref: 'y',
line: { color: 'white', width: 1 } line: { color: plotColors.text, width: 1 }
} }
], ],
}; };
}, [apiData, plotYAxisType]); }, [plotYAxisType, plotColors]);
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="transparent" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" /> <Spinner size="xl" color="blue.400" thickness="4px" />
</Flex> </Flex>
); );
if (error) return ( if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}> <Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text> <Text color="red.400">{error}</Text>
</Flex> </Flex>
); );
if (plotTraces.length === 0) return ( if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md"> <Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="gray.400">No data available for current filter.</Text> <Text color="var(--chakra-colors-widgetTextMuted)">No data available for current filter.</Text>
</Flex> </Flex>
); );
return ( return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden"> <Box height="100%" width="100%" bg="transparent" overflow="hidden">
<Plot <Plot
key={plotYAxisType} key={`${plotYAxisType}-${colorMode}`}
data={plotTraces} data={plotTraces}
layout={layout} layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing! useResizeHandler={true}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
config={{ responsive: true, displayModeBar: true, displaylogo: false }} config={{ responsive: true, displayModeBar: true, displaylogo: false }}
/> />
</Box> </Box>
); );
} }
+36 -33
View File
@@ -1,4 +1,4 @@
import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react"; import {Button, Flex, Spinner, Text, Box, Collapsible, Highlight} from "@chakra-ui/react";
import {useEffect, useMemo, useCallback } from 'react'; import {useEffect, useMemo, useCallback } from 'react';
import {useDashboardStore} from "@/store/useDashboardStore.jsx"; import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import VdapApi from '../../hooks/useVdapApi.js'; import VdapApi from '../../hooks/useVdapApi.js';
@@ -43,30 +43,31 @@ export default function IvansAlerts({ settings, widgetID }) {
}).join('\n\n'); }).join('\n\n');
navigator.clipboard.writeText(dataText) navigator.clipboard.writeText(dataText)
.then(() => { .then(() => {
alert('Copied to clipboard!'); alert('Copied to clipboard!');
}) })
.catch((err => { .catch((err => {
console.error('Failed to copy: ', err); console.error('Failed to copy: ', err);
})); }));
updateSettings(widgetID, {copyToClipboard: false}); updateSettings(widgetID, {copyToClipboard: false});
} }
}, [settings, some_data, widgetID, updateSettings]); }, [settings, some_data, widgetID, updateSettings]);
// Cleaned hardcoded grays for transparent so glass shell shows through
if (isLoading) return ( if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px"/> <Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex> </Flex>
); );
if (error) return ( if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Text color="red.400">{error}</Text> <Text color="red.400">{error}</Text>
</Flex> </Flex>
); );
if (some_data.length === 0) return ( if (some_data.length === 0) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text> <Text textAlign="center" color="widgetTextMuted">No flight level data available for the selected dates.</Text>
</Flex> </Flex>
); );
@@ -78,7 +79,7 @@ export default function IvansAlerts({ settings, widgetID }) {
p={2} gap={2} p={2} gap={2}
overflow="auto" overflow="auto"
borderRadius="xl" borderRadius="xl"
bg="gray.800" bg="transparent" // Let the global widgetRecipe glass shell show through
> >
{some_data.map((item, index) => { {some_data.map((item, index) => {
if (thisWidget?.size === "large") return ( if (thisWidget?.size === "large") return (
@@ -96,47 +97,48 @@ export default function IvansAlerts({ settings, widgetID }) {
gap={2} gap={2}
borderTopRadius="lg" borderTopRadius="lg"
borderBottomRadius={collapsible.open ? "0" : "lg"} borderBottomRadius={collapsible.open ? "0" : "lg"}
bg="gray.600" bg="widgetItemBg"
> >
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100"> <Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Text truncate>{item.volcano_name}</Text> <Text truncate color="text" fontWeight="medium">{item.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item.send_time}</Text> <Text truncate fontSize="xs" color="widgetTextMuted">{item.send_time}</Text>
</Flex> </Flex>
<Flex w="100%" minW={0} justify="right"> <Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{item.report_num}</Text> <Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontWeight="semibold">{item.report_num}</Text>
</Flex> </Flex>
</Flex> </Flex>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content w="100%" h="auto"> <Collapsible.Content w="100%" h="auto">
{/* This box needs to be at h="100%" for the pdf toolbar to appear */} {/* This box needs to be at h="100%" for the pdf toolbar to appear */}
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45"> <Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="widgetItemExpandedBg">
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end"> <Flex direction="column" gap={2} borderRadius="lg" p={2} align="start">
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text> <Text w="fit" fontSize="sm" color="text" p={1} bg="widgetBadgeBg" borderRadius="md">Preparer: {item?.preparer}</Text>
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Preparer: {item?.preparer}</Text> <Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
</Flex> </Flex>
{item?.files?.map((file, index) => { {item?.files?.map((file, index) => {
return ( return (
<Box w="100%" h="100%" key={index} p={1}> <Box w="100%" h="100%" key={index} p={1}>
<br /> <Flex direction="row" mb={2} gap={1} justify="space-between">
<Flex mb={2} justify="space-between"> <a href={`${file?.url}#view=FitV&toolbar=0`} target="_blank" rel="noopener noreferrer">
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text> <Text mr={1} truncate p={1} bg="widgetBadgeBg" borderRadius="lg" fontSize="sm" color="text">
<Box borderRadius="lg" bg="gray.700" color="gray.300" pl={1} pr={1} fontSize={13} asChild> File: {file?.name}
<a href={`${file?.url}#view=FitV&toolbar=1`} target="_blank" rel="noopener noreferrer"> </Text>
Open </a>
</a> {/*<Box borderRadius="lg" bg="widgetBadgeBg" color="text" p={1} fontSize="sm" asChild>*/}
</Box> {/* Open*/}
{/*</Box>*/}
</Flex> </Flex>
<object <object
data={`${file?.url}#view=FitH&toolbar=1`} data={`${file?.url}#view=FitH&toolbar=1`}
type="application/pdf" type="application/pdf"
style={{ width: '100%', height: '100%', display: 'block' }} style={{ width: '100%', height: '100%', display: 'block' }}
> >
<Flex w="100%" h="100%" p={4} color="gray.400"> <Flex direction="column" w="100%" h="100%" align="center" p={4} color="widgetTextMuted" bg="widgetBadgeBg" borderRadius="md">
<Text mb={2} color="fg.muted"> <Text mb={2} fontSize="xs" color="red.300">
Your browser cannot display this PDF directly. Your browser cannot display this PDF directly.
</Text> </Text>
<a href={file?.url} target="_blank"> <a href={file?.url} target="_blank" bg="widgetBadgeBg" rel="noopener noreferrer">
Download PDF <Text fontSize="xs" bg="widgetInnerBadgeBg" borderRadius="md" p={1}>Download PDF Instead</Text>
</a> </a>
</Flex> </Flex>
</object> </object>
@@ -151,19 +153,20 @@ export default function IvansAlerts({ settings, widgetID }) {
) )
else if (thisWidget?.size === "small") return ( else if (thisWidget?.size === "small") return (
<Flex direction="row" <Flex direction="row"
key={index} // Added missing map key
w="100%" w="100%"
justify="space-between" align="center" justify="space-between" align="center"
p={1} pl={3} pr={3} p={1} pl={3} pr={3}
gap={2} gap={2}
borderRadius="lg" borderRadius="lg"
bg="gray.600" bg="widgetItemBg"
> >
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100"> <Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Text truncate>{item.volcano_name}</Text> <Text truncate>{item.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item.send_time}</Text> <Text truncate fontSize="xs" color="widgetTextMuted">{item.send_time}</Text>
</Flex> </Flex>
<Flex w="100%" minW={0} justify="right"> <Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{item.report_num}</Text> <Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontWeight="semibold">{item.report_num}</Text>
</Flex> </Flex>
</Flex> </Flex>
) )
@@ -8,10 +8,6 @@ export default function LlmAlChangesQuadPlot(props) {
let tn_percent = 0; let tn_percent = 0;
let fp_percent = 0; let fp_percent = 0;
let fn_percent = 0; let fn_percent = 0;
let jf_percent = 0;
let error_count = 0;
let no_dvar_data_count = 0;
// Clean, single-line data fetching // Clean, single-line data fetching
const { data: apiData, isLoading, error } = VdapApi('/api/v2/llm_quadrant_plot'); const { data: apiData, isLoading, error } = VdapApi('/api/v2/llm_quadrant_plot');
@@ -36,34 +32,32 @@ export default function LlmAlChangesQuadPlot(props) {
if (apiData) { if (apiData) {
console.log(apiData); console.log(apiData);
tp_percent = ((apiData[0]['tp_count']/apiData[0]['vr_count'])*100).toFixed(2); tp_percent = ((apiData[0]['tp_count']/apiData[0]['tot_count'])*100).toFixed(2);
tn_percent = ((apiData[0]['tn_count']/apiData[0]['vr_count'])*100).toFixed(2); tn_percent = ((apiData[0]['tn_count']/apiData[0]['tot_count'])*100).toFixed(2);
fp_percent = ((apiData[0]['fp_count']/apiData[0]['vr_count'])*100).toFixed(2); fp_percent = ((apiData[0]['fp_count']/apiData[0]['tot_count'])*100).toFixed(2);
fn_percent = ((apiData[0]['fn_count']/apiData[0]['vr_count'])*100).toFixed(2); fn_percent = ((apiData[0]['fn_count']/apiData[0]['tot_count'])*100).toFixed(2);
jf_percent = ((apiData[0]['jf_count']/apiData[0]['vr_count'])*100).toFixed(2);
error_count = apiData[0]['e_count'];
no_dvar_data_count = apiData[0]['ndd_count'];
} }
return ( return (
<Flex direction="column" w="100%" h="100%" bg="gray.800" borderRadius="lg" gap={1} p={1}> <Flex direction="column" w="100%" h="100%" borderRadius="lg">
<Flex direction="column" align="end" justify="center" color="white" gap={1}> <Flex direction="row" w="100%" bg="gray.800" borderTopRadius="lg" justifyItems="center" alignItems="start" p={2} pb={0} pl={4} justifyContent="end">
<Text w="fit" bg="cyan.200/5" borderRadius="sm" p={1}>All Time</Text> {/*<Text fontWeight="bold" fontSize="lg" color="white">Alert Level Changes Quad Plot</Text>*/}
<Text bg="cyan.200/5" borderRadius="sm" padding="1" fontWeight="normal" fontSize="sm" color="white">All time</Text>
</Flex> </Flex>
<Flex direction="column" w="100%" h="100%" gap="2" borderBottomRadius="lg"> <Flex direction="column" w="100%" h="100%" bg="gray.800" gap="2" padding="2" borderBottomRadius="lg">
<Flex direction="row" w="100%" h="100%" gap="2"> <Flex direction="row" w="100%" h="100%" gap="2">
<Center bg={`blue.200/${tp_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold"> <Center bg={`blue.200/${tp_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold">
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center"> <Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>True Positive</Text> <Text>True Positive</Text>
<Text fontSize="lg"> {tp_percent}% </Text> <Text fontSize="lg"> {tp_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['tp_count']}/{apiData[0]['vr_count']} </Text> <Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['tp_count']}/{apiData[0]['tot_count']} </Text>
</Flex> </Flex>
</Center> </Center>
<Center bg={`blue.200/${tn_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold"> <Center bg={`blue.200/${tn_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold">
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center"> <Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>True Negative</Text> <Text>True Negative</Text>
<Text fontSize="lg"> {tn_percent}% </Text> <Text fontSize="lg"> {tn_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['tn_count']}/{apiData[0]['vr_count']} </Text> <Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['tn_count']}/{apiData[0]['tot_count']} </Text>
</Flex> </Flex>
</Center> </Center>
</Flex> </Flex>
@@ -72,30 +66,17 @@ export default function LlmAlChangesQuadPlot(props) {
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center"> <Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>False Positive</Text> <Text>False Positive</Text>
<Text fontSize="lg"> {fp_percent}% </Text> <Text fontSize="lg"> {fp_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['fp_count']}/{apiData[0]['vr_count']} </Text> <Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['fp_count']}/{apiData[0]['tot_count']} </Text>
</Flex> </Flex>
</Center> </Center>
<Center bg={`blue.200/${fn_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold"> <Center bg={`blue.200/${fn_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold">
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center"> <Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>False Negative</Text> <Text>False Negative</Text>
<Text fontSize="lg"> {fn_percent}% </Text> <Text fontSize="lg"> {fn_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['fn_count']}/{apiData[0]['vr_count']} </Text> <Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['fn_count']}/{apiData[0]['tot_count']} </Text>
</Flex> </Flex>
</Center> </Center>
</Flex> </Flex>
<Flex direction="row" w="100%" h="100%" gap="2">
<Center bg={`blue.200/${jf_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold">
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>Just False</Text>
<Text fontSize="lg"> {jf_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['jf_count']}/{apiData[0]['vr_count']} </Text>
</Flex>
</Center>
</Flex>
</Flex>
<Flex direction="row" w="100%" justify="space-between" gap={1} color="white">
<Text w="fit" bg="blue.200/5" borderRadius="sm" p={1}>Errors: {error_count}</Text>
<Text w="fit" bg="blue.200/5" borderRadius="sm" p={1}>No DVAR Data: {no_dvar_data_count}</Text>
</Flex> </Flex>
</Flex> </Flex>
) )
+30 -33
View File
@@ -1,10 +1,18 @@
import VdapApi from '../../hooks/useVdapApi.js';
import { useMemo } from 'react'; 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 Plot from 'react-plotly.js';
import {Box, Flex, Spinner, Text} from "@chakra-ui/react"; import VdapApi from '../../hooks/useVdapApi.js';
import useBuildUrl from "@/hooks/useBuildUrl.js"; import useBuildUrl from "@/hooks/useBuildUrl.js";
// 1. Import your central color dictionary
import { rawWidgetColors } from '../../theme.js';
export default function PlumeEvents({ settings, widgetID}) { export default function PlumeEvents({ settings, widgetID}) {
const { colorMode } = useColorMode();
// 2. Assign the active palette based on the current mode
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
// Build url, defaults to 7 days past // Build url, defaults to 7 days past
const url = useBuildUrl('/api/v2/plume_events', settings?.startDate, settings?.endDate); const url = useBuildUrl('/api/v2/plume_events', settings?.startDate, settings?.endDate);
@@ -44,7 +52,7 @@ export default function PlumeEvents({ settings, widgetID}) {
mode: 'markers', mode: 'markers',
marker: { marker: {
size: 7, size: 7,
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' }, line: { width: .5, color: plotColors.text },
symbol: Symbolway[index % Symbolway.length], symbol: Symbolway[index % Symbolway.length],
}, },
// Replicating the clean D3/Observable tooltip // Replicating the clean D3/Observable tooltip
@@ -53,88 +61,77 @@ export default function PlumeEvents({ settings, widgetID}) {
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' + 'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
'Plume Height (m): %{y}<extra></extra>' 'Plume Height (m): %{y}<extra></extra>'
})); }));
}, [apiData]); }, [apiData, plotColors]);
const layout = useMemo(() => { const layout = useMemo(() => {
return { return {
autosize: true, autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through paper_bgcolor: 'transparent', // Let Chakra UI background show through
plot_bgcolor: 'transparent', plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300 font: { color: plotColors.muted },
margin: { t: 40, r: 20, l: 20, b: 20 }, 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: { xaxis: {
title: { title: {
text: 'Date (UTC)', text: 'Date (UTC)',
font: { color: 'white', size: 12 } font: { color: plotColors.text, size: 12 }
}, },
type: 'date', type: 'date',
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting tickformat: '%b %d<br>%Y',
gridcolor: '#4b5563', gridcolor: plotColors.grid,
zerolinecolor: '#4b5563', zerolinecolor: plotColors.grid,
automargin: true, automargin: true,
}, },
yaxis: { yaxis: {
title: { title: {
text: 'Plume Height', text: 'Plume Height',
font: { color: 'white', size: 12 } font: { color: plotColors.text, size: 12 }
}, },
type: 'linear', type: 'linear',
gridcolor: '#4b5563', gridcolor: plotColors.grid,
zerolinecolor: '#ffffff', zerolinecolor: plotColors.text,
automargin: true, automargin: true,
}, },
// legend: {
// orientation: 'h',
// y: -0.2,
// x: 0.5,
// xanchor: 'center'
// },
hovermode: 'closest', hovermode: 'closest',
shapes: [ shapes: [
{ {
type: 'line', type: 'line',
x0: 0, x0: 0,
x1: 1, x1: 1,
xref: 'paper', // Spans entire chart width xref: 'paper',
y0: 0, y0: 0,
y1: 0, y1: 0,
yref: 'y', yref: 'y',
line: { color: 'white', width: 1 } line: { color: plotColors.text, width: 1 }
} }
], ],
}; };
}, []); }, [plotColors]);
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="transparent" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" /> <Spinner size="xl" color="blue.400" thickness="4px" />
</Flex> </Flex>
); );
if (error) return ( if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}> <Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text> <Text color="red.400">{error}</Text>
</Flex> </Flex>
); );
if (plotTraces.length === 0) return ( if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md"> <Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="gray.400">No data available for current filter.</Text> <Text color="widgetTextMuted">No data available for current filter.</Text>
</Flex> </Flex>
); );
return ( return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden"> <Box height="100%" width="100%" bg="transparent" overflow="hidden">
<Plot <Plot
key={colorMode}
data={plotTraces} data={plotTraces}
layout={layout} layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing! useResizeHandler={true}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
config={{ responsive: true, displayModeBar: true, displaylogo: false }} config={{ responsive: true, displayModeBar: true, displaylogo: false }}
/> />
+23 -24
View File
@@ -52,25 +52,25 @@ export default function RemoteSensing({ settings, widgetID }) {
}, [settings, data, widgetID, updateSettings]); }, [settings, data, widgetID, updateSettings]);
if (isLoading) return ( if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="md" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px"/> <Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex> </Flex>
); );
if (error) return ( if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" p={4} borderRadius="md" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" p={4} bg="transparent">
<Text color="red.400">{error}</Text> <Text color="red.400">{error}</Text>
</Flex> </Flex>
); );
if (data.length === 0) return ( if (data.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md"> <Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="gray.400">No Remote Sensing data available this week.</Text> <Text color="widgetTextMuted">No Remote Sensing data available this week.</Text>
</Flex> </Flex>
); );
return ( return (
<Flex direction="column" w="100%" h="100%" pos="relative" justify="top" align="center" p={2} gap={2} overflow="auto" borderRadius="xl" bg="gray.800"> <Flex direction="column" w="100%" h="100%" pos="relative" justify="top" align="center" p={2} gap={2} overflow="auto" bg="transparent">
{data.map((item, index) => { {data.map((item, index) => {
if (thisWidget?.size === "large" || thisWidget?.size === "medium") return ( if (thisWidget?.size === "large" || thisWidget?.size === "medium") return (
<Collapsible.Root key={index} w="100%"> <Collapsible.Root key={index} w="100%">
@@ -80,14 +80,14 @@ export default function RemoteSensing({ settings, widgetID }) {
<> <>
<Collapsible.Trigger w="100%" overflow="hidden"> <Collapsible.Trigger w="100%" overflow="hidden">
<Flex direction="row" w="100%" h="100%" justify="space-between" align="center" gap={2} p={1} pl={3} pr={3} <Flex direction="row" 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.600" borderTopRadius="lg" borderBottomRadius={collapsible.open ? "0" : "lg"} bg="widgetItemBg"
> >
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100"> <Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Text truncate>{item?.volcano_name}</Text> <Text truncate>{item?.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item?.country}</Text> <Text truncate fontSize="xs" color="widgetTextMuted">{item?.country}</Text>
</Flex> </Flex>
<Flex w="100%" minW={0} justify="right"> <Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontSize="sm" fontWeight="semibold"> <Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontSize="sm" fontWeight="semibold">
{item?.report_time} {item?.report_time}
</Text> </Text>
</Flex> </Flex>
@@ -95,10 +95,10 @@ export default function RemoteSensing({ settings, widgetID }) {
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content w="100%" h="auto"> <Collapsible.Content w="100%" h="auto">
{/* This box needs to be at h="100%" for the pdf toolbar to appear */} {/* This box needs to be at h="100%" for the pdf toolbar to appear */}
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45"> <Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="widgetItemExpandedBg">
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end"> <Flex direction="column" gap={2} borderRadius="lg" p={2} align="end">
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text> <Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Source: {item?.sender}</Text> <Text w="fit" fontSize="sm" color="text" pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md">Source: {item?.sender}</Text>
</Flex> </Flex>
{item?.files?.map((file, index) => { {item?.files?.map((file, index) => {
@@ -107,15 +107,15 @@ export default function RemoteSensing({ settings, widgetID }) {
<Box w="100%" h="100%" key={index} p={1}> <Box w="100%" h="100%" key={index} p={1}>
<br /> <br />
<Flex dir="row" justify="space-between" align="center" mb={2}> <Flex dir="row" justify="space-between" align="center" mb={2}>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text> <Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PDF</Text> <Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="widgetTextMuted">PDF</Text>
</Flex> </Flex>
<object <object
data={`${file?.url}#view=FitH&toolbar=1`} data={`${file?.url}#view=FitH&toolbar=1`}
type="application/pdf" type="application/pdf"
style={{width: '100%', height: '100%', display: 'block'}} style={{width: '100%', height: '100%', display: 'block'}}
> >
<Flex w="100%" h="100%" p={4} color="gray.400"> <Flex w="100%" h="100%" p={4} color="widgetTextMuted">
<Text mb={2} color="fg.muted"> <Text mb={2} color="fg.muted">
Your browser cannot display this PDF directly. Your browser cannot display this PDF directly.
</Text> </Text>
@@ -132,8 +132,8 @@ export default function RemoteSensing({ settings, widgetID }) {
<Box key={index} w="100%" h="100%" p={1}> <Box key={index} w="100%" h="100%" p={1}>
<br /> <br />
<Flex dir="row" justify="space-between" align="center" mb={2}> <Flex dir="row" justify="space-between" align="center" mb={2}>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text> <Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PNG</Text> <Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="widgetTextMuted">PNG</Text>
</Flex> </Flex>
<a href={file?.url} target="_blank" rel="noopener noreferrer"> <a href={file?.url} target="_blank" rel="noopener noreferrer">
<img src={file?.url} alt={file?.name} /> <img src={file?.url} alt={file?.name} />
@@ -146,7 +146,7 @@ export default function RemoteSensing({ settings, widgetID }) {
<> <>
<br /> <br />
<Flex key={index} dir="row" w="100%" h="100%" p={1} align="center" justify="space-between"> <Flex key={index} dir="row" w="100%" h="100%" p={1} align="center" justify="space-between">
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text> <Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
<Button asChild variant="surface" size="xxs" fontSize="xs" pl={2} pr={2}> <Button asChild variant="surface" size="xxs" fontSize="xs" pl={2} pr={2}>
<a href={file?.url} target="_blank" rel="noopener noreferrer">Download</a> <a href={file?.url} target="_blank" rel="noopener noreferrer">Download</a>
</Button> </Button>
@@ -162,17 +162,17 @@ export default function RemoteSensing({ settings, widgetID }) {
</Collapsible.Root> </Collapsible.Root>
) )
else if (thisWidget?.size === "small") return ( else if (thisWidget?.size === "small") return (
<Flex key={index} direction="row" w="100%" justify="space-between" align="center" gap={2} p={1} pl={3} pr={3} borderRadius="lg" bg="gray.600"> <Flex key={index} direction="row" w="100%" justify="space-between" align="center" gap={2} p={1} pl={3} pr={3} borderRadius="lg" bg="widgetItemBg">
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100"> <Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Text truncate>{item?.volcano_name}</Text> <Text truncate>{item?.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item?.country}</Text> <Text truncate fontSize="xs" color="widgetTextMuted">{item?.country}</Text>
</Flex> </Flex>
<Flex w="100%" minW={0} justify="right"> <Flex w="100%" minW={0} justify="right">
<Text <Text
truncate truncate
pl={2} pr={2} pl={2} pr={2}
borderRadius="lg" borderRadius="lg"
bg="gray.800/50" color="gray.100" bg="widgetBadgeBg" color="text"
fontSize="sm" fontWeight="semibold" fontSize="sm" fontWeight="semibold"
> >
{item?.report_time} {item?.report_time}
@@ -185,7 +185,6 @@ export default function RemoteSensing({ settings, widgetID }) {
); );
} }
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
+26 -29
View File
@@ -1,10 +1,18 @@
import VdapApi from '../../hooks/useVdapApi.js'; import VdapApi from '../../hooks/useVdapApi.js';
import { useMemo } from 'react'; import { useMemo } from 'react';
import Plot from 'react-plotly.js'; import Plot from 'react-plotly.js';
import {Box, Flex, Spinner, Text} from "@chakra-ui/react"; import { Box, Flex, Spinner, Text } from "@chakra-ui/react";
import useBuildUrl from "@/hooks/useBuildUrl.js"; import useBuildUrl from "@/hooks/useBuildUrl.js";
// 1. Import Chakra color mode and your central color dictionary
import { useColorMode } from "@/components/ui/color-mode.jsx";
import { rawWidgetColors } from '../../theme.js';
export default function SeismicEvents({ settings, widgetID}) { export default function SeismicEvents({ settings, widgetID}) {
const { colorMode } = useColorMode();
// 2. Assign the active palette based on the current mode
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
// build url, defaults to 7 days past // build url, defaults to 7 days past
const url = useBuildUrl('/api/v2/seismic_events', settings?.startDate, settings?.endDate); const url = useBuildUrl('/api/v2/seismic_events', settings?.startDate, settings?.endDate);
@@ -44,7 +52,7 @@ export default function SeismicEvents({ settings, widgetID}) {
mode: 'markers', mode: 'markers',
marker: { marker: {
size: 7, size: 7,
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' }, line: { width: .5, color: plotColors.text },
symbol: Symbolway[index % Symbolway.length], symbol: Symbolway[index % Symbolway.length],
}, },
// Replicating the clean D3/Observable tooltip // Replicating the clean D3/Observable tooltip
@@ -53,48 +61,36 @@ export default function SeismicEvents({ settings, widgetID}) {
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' + 'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
'Number of Seismic Events: %{y}<extra></extra>' 'Number of Seismic Events: %{y}<extra></extra>'
})); }));
}, [apiData]); }, [apiData, plotColors]);
const layout = useMemo(() => { const layout = useMemo(() => {
return { return {
autosize: true, autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through paper_bgcolor: 'transparent', // Let Chakra UI background show through
plot_bgcolor: 'transparent', plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300 font: { color: plotColors.muted },
margin: { t: 40, r: 20, l: 20, b: 20 }, margin: { t: 40, r: 20, l: 20, b: 20 },
// title: {
// text: '',
// font: { size: 16, color: 'white' },
// x: 0.05
// },
xaxis: { xaxis: {
title: { title: {
text: 'Date (UTC)', text: 'Date (UTC)',
font: { color: 'white', size: 12 } font: { color: plotColors.text, size: 12 }
}, },
type: 'date', type: 'date',
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
gridcolor: '#4b5563', gridcolor: plotColors.grid,
zerolinecolor: '#4b5563', zerolinecolor: plotColors.grid,
automargin: true, automargin: true,
}, },
yaxis: { yaxis: {
title: { title: {
text: 'Seismic Count', text: 'Seismic Count',
font: { color: 'white', size: 12 } font: { color: plotColors.text, size: 12 }
}, },
type: 'linear', type: 'linear',
gridcolor: '#4b5563', gridcolor: plotColors.grid,
zerolinecolor: '#ffffff', zerolinecolor: plotColors.text,
automargin: true, automargin: true,
}, },
// legend: {
// orientation: 'h',
// y: -0.2,
// x: 0.5,
// xanchor: 'center'
// },
hovermode: 'closest', hovermode: 'closest',
shapes: [ shapes: [
{ {
@@ -105,33 +101,34 @@ export default function SeismicEvents({ settings, widgetID}) {
y0: 0, y0: 0,
y1: 0, y1: 0,
yref: 'y', yref: 'y',
line: { color: 'white', width: 1 } line: { color: plotColors.text, width: 1 }
} }
], ],
}; };
}, []); }, [plotColors]);
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="transparent" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" /> <Spinner size="xl" color="blue.400" thickness="4px" />
</Flex> </Flex>
); );
if (error) return ( if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}> <Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text> <Text color="red.400">{error}</Text>
</Flex> </Flex>
); );
if (plotTraces.length === 0) return ( if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md"> <Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="gray.400">No data available for current filter.</Text> <Text color="var(--chakra-colors-widgetTextMuted)">No data available for current filter.</Text>
</Flex> </Flex>
); );
return ( return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden"> <Box height="100%" width="100%" bg="transparent" overflow="hidden">
<Plot <Plot
key={colorMode}
data={plotTraces} data={plotTraces}
layout={layout} layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing! useResizeHandler={true} // Essential for dashboard grid resizing!
@@ -1,11 +1,18 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Box, Flex, Spinner, Text } from '@chakra-ui/react'; import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
import Plot from 'react-plotly.js'; import Plot from 'react-plotly.js';
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed import VdapApi from '../../hooks/useVdapApi.js';
import { useColorMode } from "@/components/ui/color-mode.jsx";
// Import central color dictionary
import { rawWidgetColors } from '../../theme.js'; // Adjust path if needed
export default function VaaActivity7Day({ settings, widgetID }) { export default function VaaActivity7Day({ settings, widgetID }) {
console.log("3. Chart received settings:", settings); const { colorMode } = useColorMode();
// Plotly expects 'linear' for normal axes
// 2. Assign the active palette based on the current mode
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear'; const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
// Clean, single-line data fetching // Clean, single-line data fetching
@@ -17,13 +24,10 @@ export default function VaaActivity7Day({ settings, widgetID }) {
// Array of distinct Plotly shapes // Array of distinct Plotly shapes
const Symbolway = [ const Symbolway = [
'circle', 'square', 'diamond', 'circle', 'square', 'diamond', 'triangle-up', 'triangle-down',
'triangle-up', 'triangle-down', 'star', 'hexagram', 'pentagon', 'hexagon', 'octagon', 'cross', 'x'
'star', 'hexagram', 'pentagon',
'hexagon', 'octagon', 'cross', 'x'
]; ];
// Sort data descending by flight level (matching Observable behavior)
const sortedData = [...apiData].sort((a, b) => b.obs_fl - a.obs_fl); const sortedData = [...apiData].sort((a, b) => b.obs_fl - a.obs_fl);
// Efficiently group into Plotly traces by volcano // Efficiently group into Plotly traces by volcano
@@ -44,102 +48,90 @@ export default function VaaActivity7Day({ settings, widgetID }) {
mode: 'markers', mode: 'markers',
marker: { marker: {
size: 7, size: 7,
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' }, line: { width: 0.5, color: plotColors.text },
symbol: Symbolway[index % Symbolway.length], symbol: Symbolway[index % Symbolway.length],
}, },
// Replicating the clean Observable tooltip // Replicating the clean Observable tooltip
hovertemplate: hovertemplate:
`<b>${volcanoName}</b><br>` + `<b>${volcanoName}</b><br>` +
'Date: %{x|%b %d %Y %H:%M}<br>' + 'Date: %{x|%b %d %Y %H:%M}<br>' +
'Flight Level: %{y}<extra></extra>' 'Flight Level: %{y}<extra></extra>'
})); }));
}, [apiData]); }, [apiData, plotColors]);
// 2. Build the Layout (Styling and Grid) // 2. Build the Layout (Styling and Grid)
const layout = useMemo(() => { const layout = useMemo(() => {
return { return {
autosize: true, autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent', plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300 font: { color: plotColors.muted },
margin: { t: 40, r: 20, l: 20, b: 20 }, 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: { xaxis: {
title: { title: {
text: 'Date (UTC)', text: 'Date (UTC)',
font: { color: 'white', size: 12 } font: { color: plotColors.text, size: 12 }
}, },
type: 'date', type: 'date',
tickformat: '%b %d<br>%Y %H:%M', // Multiline ticks from Observable tickformat: '%b %d<br>%Y %H:%M',
gridcolor: '#4b5563', gridcolor: plotColors.grid,
zerolinecolor: '#4b5563', zerolinecolor: plotColors.grid,
automargin: true, automargin: true,
}, },
yaxis: { yaxis: {
title: { title: {
text: 'Flight Level', text: 'Flight Level',
font: { color: 'white', size: 12 } font: { color: plotColors.text, size: 12 }
}, },
type: plotYAxisType, type: plotYAxisType,
gridcolor: '#4b5563', gridcolor: plotColors.grid,
zerolinecolor: '#ffffff', zerolinecolor: plotColors.text,
automargin: true, automargin: true,
}, },
// legend: {
// orientation: 'h',
// y: -0.2,
// x: 0.5,
// xanchor: 'center'
// },
hovermode: 'closest', hovermode: 'closest',
shapes: [ shapes: [
{ {
type: 'line', type: 'line',
x0: 0, x0: 0,
x1: 1, x1: 1,
xref: 'paper', // Spans entire chart width xref: 'paper',
y0: 0, y0: 0,
y1: 0, y1: 0,
yref: 'y', yref: 'y',
line: { color: 'white', width: 1 } line: { color: plotColors.text, width: 1 }
} }
], ],
}; };
}, [apiData, plotYAxisType]); }, [plotYAxisType, plotColors]);
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="transparent" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" /> <Spinner size="xl" color="blue.400" thickness="4px" />
</Flex> </Flex>
); );
if (error) return ( if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}> <Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text> <Text color="red.400">{error}</Text>
</Flex> </Flex>
); );
if (plotTraces.length === 0) return ( if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md"> <Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="gray.400">No flight level data available.</Text> <Text color="widgetTextMuted">No flight level data available.</Text>
</Flex> </Flex>
); );
return ( return (
<Box height="100%" width="100%" bg="gray.800" borderRadius="md" p={2} overflow="hidden"> <Box height="100%" width="100%" bg="transparent" overflow="hidden">
<Plot <Plot
key={plotYAxisType} key={`${plotYAxisType}-${colorMode}`}
data={plotTraces} data={plotTraces}
layout={layout} layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing! useResizeHandler={true}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
config={{ responsive: true, displayModeBar: true, displaylogo: false }} config={{ responsive: true, displayModeBar: true, displaylogo: false }}
/> />
</Box> </Box>
); );
} }
@@ -1,36 +1,289 @@
import {Flex, Slider, Switch} from "@chakra-ui/react"; import {
import {useDashboardStore} from "@/store/useDashboardStore.jsx"; Flex,
Slider,
Switch,
Text,
Field,
Fieldset,
Input,
CheckboxCard,
CheckboxGroup,
Combobox,
Portal,
useFilter,
useListCollection,
Wrap,
Badge
} from "@chakra-ui/react";
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
import { useState, useMemo } from "react";
export default function IvansAlertsSettings({ widgetId, currentSettings }) { export default function AlertChangeSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings); const updateSettings = useDashboardStore(
(state) => state.updateWidgetSettings,
);
const [startError, setStartError] = useState(null);
const [endError, setEndError] = useState(null);
console.log("currentSettings:", currentSettings);
const [selectedVolcanoes, setSelectedVolcanoes] = useState([]);
const { startsWith } = useFilter({ sensitivity: "base" });
const { collection, filter, reset } = useListCollection({
initialItems: volcanoes,
filter: startsWith,
limit: 10,
});
const handleValueChange = (details) => {
setSelectedVolcanoes(details.value)
}
return ( return (
<Flex flexDirection="column" justifyContent="space-between" gap={2} pb={2}> <Flex direction="column" pb={2} overflowY="auto">
{/* SLIDER DATE RANGE SELECTION */} <Fieldset.Root>
<Slider.Root <Fieldset.Content>
width="80%" h="100%" {/* SELECTING DATE RANGE */}
min={1} max={14} <Flex direction="column" w="100%" h="100%" justify="center" gap={2}>
defaultValue={[7]} <Flex direction="column">
step={1} <Text w="100%" fontWeight="bold">
onValueChangeEnd={(e) => { Date Range Selection
const day = new Date(Date.now() - (e.value[0])*86400000).toLocaleDateString('en-CA'); </Text>
console.log("new start: ", day); <hr /> {/*line seperator*/}
updateSettings(widgetId, {...currentSettings, startDate: day}); </Flex>
}}
> {/* start date */}
<Slider.Label>{currentSettings?.endDate} to {currentSettings?.startDate}</Slider.Label> <Field.Root invalid={!!startError}>
<Slider.Control> <Flex
<Slider.Track> direction="row"
<Slider.Range /> w="100%"
</Slider.Track> h="100%"
<Slider.Thumbs /> align="center"
<Slider.MarkerGroup p={3}> justify="center"
<Slider.Marker value={1} whiteSpace="nowrap">today</Slider.Marker> >
<Slider.Marker value={7} whiteSpace="nowrap">1 week ago</Slider.Marker> <Text w="50%" align="center" justify="center" color="text">
<Slider.Marker value={14} whiteSpace="nowrap">2 weeks ago</Slider.Marker> Start Date
</Slider.MarkerGroup> </Text>
</Slider.Control> <Flex direction="column" w="100%">
</Slider.Root> <Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.startDate || ""}
onChange={(e) => {
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 && (
<Field.ErrorText>{startError}</Field.ErrorText>
)}
</Flex>
</Flex>
</Field.Root>
{/* end date */}
<Field.Root invalid={!!endError}>
<Flex
direction="row"
w="100%"
h="100%"
align="center"
justify="center"
>
<Text w="50%" align="center" justify="center" color="text">
End Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.endDate || ""}
onChange={(e) => {
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 && <Field.ErrorText>{endError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
</Flex>
{/* FILTERS */}
<CheckboxGroup w="100%">
<Flex direction="column" w="100%" gap={2}>
<Flex direction="column" w="100%">
<Text w="fit" fontWeight="bold">
Filters
</Text>
<hr /> {/*line seperator*/}
</Flex>
{/* level increases */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.levelIncreases}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
levelIncreases: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput /> {/* when removed can't check/uncheck box */}
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Level Increases
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
{/* level decreases */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.levelDecreases}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
levelDecreases: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput />
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Level Decreases
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
{/* elevated levels */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.elevatedLevels}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
elevatedLevels: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput />
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Only Elevated Levels
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
</Flex>
</CheckboxGroup>
{/* select volcano to filter on */}
<Combobox.Root
mt={1}
variant="subtle"
multiple
openOnClick
collection={collection}
onValueChange={handleValueChange}
onInputValueChange={(e) => filter(e.inputValue)}
>
<Wrap gap="2">
{selectedVolcanoes.map((volcano) => (
<Badge key={volcano}>{volcano}</Badge>
))}
</Wrap>
<Combobox.Label fontSize="xs" >Select Volcano (s)</Combobox.Label>
<Combobox.Control>
<Combobox.Input placeholder="Type to search" borderRadius="lg"/>
<Combobox.IndicatorGroup>
<Combobox.ClearTrigger />
<Combobox.Trigger onClick={reset} />
</Combobox.IndicatorGroup>
</Combobox.Control>
<Portal>
<Combobox.Positioner>
<Combobox.Content>
{volcanoes.map((item) => (
<Combobox.Item item={item} key={item.value}>
{item.label}
<Combobox.ItemIndicator />
</Combobox.Item>
))}
<Combobox.Empty>No items found</Combobox.Empty>
</Combobox.Content>
</Combobox.Positioner>
</Portal>
</Combobox.Root>
</Fieldset.Content>
</Fieldset.Root>
</Flex> </Flex>
) );
} }
const volcanoes = [
{ label: "Mt. St. Helens", value: "helens" },
{ label: "Mt. Adams", value: "adams" },
{ label: "Rainier", value: "rainier" },
{ label: "The Three Sisters", value: "three sisters" },
{ label: "Fuego", value: "fuego" },
{ label: "Laki Laki", value: "lakilaki" },
{ label: "Lewotobi", value: "lewotobi" },
{ label: "Karangatang", value: "karangatang" },
{ label: "Cotopaxi", value: "cotopaxi" },
{ label: "Merapi", value: "merapi" },
{ label: "Marapi", value: "marapi" },
{ label: "Kick 'Em Jenny", value: "kick em jenny"}
]
@@ -1,4 +1,4 @@
import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react"; import {Button, Field, Flex, Input, Switch, Text, Fieldset, CheckboxCard} from "@chakra-ui/react";
import {useDashboardStore} from "../../../store/useDashboardStore.jsx"; import {useDashboardStore} from "../../../store/useDashboardStore.jsx";
import {useState} from "react"; import {useState} from "react";
@@ -20,89 +20,177 @@ export default function IvansAlertsSettings({ widgetId, currentSettings }) {
} }
return ( return (
<Flex flexDirection="column" justifyContent="space-between" gap={2} overflowY="auto"> <Flex flexDirection="column" justifyContent="space-between" overflowY="auto">
{/* COPY TO CLIPBOARD */} <Fieldset.Root>
<Button w="fit" size="xs" onClick={handleCopyToClipboard}>Copy All IVANS</Button> <Fieldset.Content>
{/* SELECTING DATE RANGE */}
{/* SORTS */} <Flex direction="column" w="100%" h="100%" justify="center" gap={2}>
<Switch.Root <Flex direction="column">
checked={currentSettings?.activeSorts?.timeSort === "asc"} <Text w="100%" fontWeight="bold">
onCheckedChange={handleTimeSort} Date Range Selection
colorPalette="blue" </Text>
> <hr style={{borderColor: "red.700"}} /> {/*line seperator*/}
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Sort Oldest to Newest</Switch.Label>
</Switch.Root>
<Switch.Root
checked={currentSettings?.activeSorts?.volcanoSort}
onCheckedChange={handleVolcanoSort}
colorPalette="blue"
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Sort by Volcano</Switch.Label>
</Switch.Root>
{/* SELECTING DATE RANGE */}
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
<Field.Root invalid={!!startError}>
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
Start Date
</Text>
<Flex direction="column" w="100%">
<Input
w="100%"
type="date"
value={currentSettings?.startDate || ""}
onChange={(e) => {
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 && <Field.ErrorText>{startError}</Field.ErrorText>}
</Flex> </Flex>
</Flex>
</Field.Root> {/* start date */}
<Field.Root invalid={!!endError}> <Field.Root invalid={!!startError}>
<Flex direction="row" w="100%" h="100%" align="center" justify="center"> <Flex
<Text w="50%" align="center" justify="center" mt={2} color="gray.700"> direction="row"
End Date
</Text>
<Flex direction="column" w="100%">
<Input
w="100%" w="100%"
type="date" h="100%"
value={currentSettings?.endDate || ""} align="center"
onChange={(e) => { justify="center"
const value = e.target.value; >
updateSettings(widgetId, {...currentSettings, endDate: value}); <Text w="50%" align="center" justify="center" color="text">
Start Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.startDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {
...currentSettings,
startDate: value,
});
const dateSelected = new Date(value); const dateSelected = new Date(value);
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date"); if (value && dateSelected > today)
else setEndError(""); setStartError("Can't Choose a Future Date");
}} else setStartError("");
/> }}
{endError && <Field.ErrorText>{endError}</Field.ErrorText>} />
</Flex> {startError && (
<Field.ErrorText>{startError}</Field.ErrorText>
)}
</Flex>
</Flex>
</Field.Root>
{/* end date */}
<Field.Root invalid={!!endError}>
<Flex
direction="row"
w="100%"
h="100%"
align="center"
justify="center"
>
<Text w="50%" align="center" justify="center" color="text">
End Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.endDate || ""}
onChange={(e) => {
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 && <Field.ErrorText>{endError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
</Flex> </Flex>
</Field.Root>
</Flex> {/* SORTS */}
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Sorts
</Text>
<hr /> {/*line seperator*/}
</Flex>
{/* time sort */}
<Switch.Root
checked={currentSettings?.activeSorts?.timeSort === "asc"}
onCheckedChange={handleTimeSort}
colorPalette="blue"
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label fontWeight={400}>Oldest to Newest</Switch.Label>
</Switch.Root>
{/* alphabetic sort */}
<Switch.Root
checked={currentSettings?.activeSorts?.volcanoSort}
onCheckedChange={handleVolcanoSort}
colorPalette="blue"
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label fontWeight={400}>Alphabetically by Volcano</Switch.Label>
</Switch.Root>
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Filters
</Text>
<hr /> {/*line seperator*/}
</Flex>
{/* elevated levels */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.elevatedLevels}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
elevatedLevels: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput />
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Only Elevated Levels
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
{/* COPY TO CLIPBOARD */}
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Tools
</Text>
<hr /> {/*line seperator*/}
<Button mt={2} w="100%" size="xs" borderRadius="lg" variant="subtle" onClick={handleCopyToClipboard}>Copy All IVANS</Button>
</Flex>
</Fieldset.Content>
</Fieldset.Root>
</Flex> </Flex>
) )
} }
@@ -38,9 +38,11 @@ export function SettingsButton() {
const [canScrollRight, setCanScrollRight] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false);
// Pull the glass container styles for the dropdown and modal // Pull the glass container styles for the dropdown and modal
const panelRecipe = useSlotRecipe({key: "glassPanel"}); const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
const panelStyles = panelRecipe({layout: "dropdown"}); const popoverStyles = popoverRecipe();
const modalStyles = panelRecipe({layout: "popover"});
const dialogRecipe = useSlotRecipe({ key: "glassDialog" });
const dialogStyles = dialogRecipe();
const [isLibraryOpen, setIsLibraryOpen] = useState(false); const [isLibraryOpen, setIsLibraryOpen] = useState(false);
@@ -130,7 +132,7 @@ export function SettingsButton() {
</Menu.Trigger> </Menu.Trigger>
<Menu.Positioner> <Menu.Positioner>
<Menu.Content css={panelStyles.root} zIndex="dropdown" minW="200px"> <Menu.Content css={popoverStyles.content} zIndex="dropdown" minW="200px">
<Menu.Item <Menu.Item
onClick={enterEditMode} onClick={enterEditMode}
cursor="pointer" cursor="pointer"
@@ -168,12 +170,13 @@ export function SettingsButton() {
{/* Soft blur can be added to the background behind the modal */} {/* Soft blur can be added to the background behind the modal */}
{/*<Dialog.Backdrop bg="blackAlpha.100" backdropFilter="blur(2px)"/>*/} {/*<Dialog.Backdrop bg="blackAlpha.100" backdropFilter="blur(2px)"/>*/}
<Dialog.Positioner> <Dialog.Positioner>
<Dialog.Content css={modalStyles.root}> <Dialog.Content css={dialogStyles.content}>
<Dialog.Header>
<Dialog.Header css={dialogStyles.header}>
<Dialog.Title>Widgets Shop</Dialog.Title> <Dialog.Title>Widgets Shop</Dialog.Title>
</Dialog.Header> </Dialog.Header>
<Dialog.Body> <Dialog.Body css={dialogStyles.body}>
<Tabs.Root defaultValue={categories[0]} variant="unstyled"> <Tabs.Root defaultValue={categories[0]} variant="unstyled">
{/* HORIZONTAL SCROLLABLE MENU OF WIDGET CATEGORIES */} {/* HORIZONTAL SCROLLABLE MENU OF WIDGET CATEGORIES */}
<Box> <Box>
@@ -246,7 +249,6 @@ export function SettingsButton() {
h="24px" h="24px"
flexShrink={0} flexShrink={0}
borderRadius="full" borderRadius="full"
// bg={{base: "blue.50", _dark: "whiteAlpha.200"}}
fontWeight="black" fontWeight="black"
fontSize="xs" fontSize="xs"
bg='transparent' bg='transparent'
+24 -20
View File
@@ -50,8 +50,8 @@ export const WIDGET_REGISTRY = {
name: "IVANS", name: "IVANS",
category: "Communications", category: "Communications",
description: "Feed of recent IVANS reports and reviewer statuses.", 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')), settingsComponent: lazy(() => import('../components/Widgets/settings/IvansAlertsSettings.jsx')),
defaultSettings: { defaultSettings: {
startDate: getStartDate(), startDate: getStartDate(),
endDate: getEndDate(), endDate: getEndDate(),
@@ -63,7 +63,6 @@ export const WIDGET_REGISTRY = {
}, },
allowedSizes: { allowedSizes: {
small: { w: 3, h: 6 }, small: { w: 3, h: 6 },
medium: { w: 3, h: 9 },
large: { w: 3, h: 12 }, large: { w: 3, h: 12 },
} }
}, },
@@ -72,8 +71,8 @@ export const WIDGET_REGISTRY = {
name: "Remote Sensing", name: "Remote Sensing",
category: "Remote Sensing", category: "Remote Sensing",
description: "Feed of recent remote sensing reports.", description: "Feed of recent remote sensing reports.",
component: lazy(() => import('../components/widgets/RemoteSensing.jsx')), component: lazy(() => import('../components/Widgets/RemoteSensing.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/RemoteSensingSettings.jsx')), settingsComponent: lazy(() => import('../components/Widgets/Settings/RemoteSensingSettings.jsx')),
defaultSettings: { defaultSettings: {
startDate: getStartDate(), startDate: getStartDate(),
endDate: getEndDate(), endDate: getEndDate(),
@@ -85,7 +84,6 @@ export const WIDGET_REGISTRY = {
}, },
allowedSizes: { allowedSizes: {
small: { w: 3, h: 6 }, small: { w: 3, h: 6 },
medium: { w: 3, h: 9 },
large: { w: 3, h: 12 }, large: { w: 3, h: 12 },
} }
}, },
@@ -94,11 +92,17 @@ export const WIDGET_REGISTRY = {
name: "Alert Level Changes", name: "Alert Level Changes",
category: "Daily Alerts", category: "Daily Alerts",
description: "List of 7-day alert level changes with trend arrows.", description: "List of 7-day alert level changes with trend arrows.",
component: lazy(() => import('../components/widgets/AlertChanges.jsx')), component: lazy(() => import('../components/Widgets/AlertChanges.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/AlertChangeSettings.jsx')), settingsComponent: lazy(() => import('../components/Widgets/settings/AlertChangeSettings.jsx')),
defaultSettings: { defaultSettings: {
startDate: getStartDate(), startDate: getStartDate(),
endDate: getEndDate(), endDate: getEndDate(),
filters: {
levelIncreases: true,
levelDecreases: true,
elevatedLevels: false, // will filter to show only volcanoes in their top 2 highest alert levels
volcanoes: 'all', // filter on what volcanoes to display, defaults to 'all' instead of listing every volc
},
}, },
allowedSizes: { allowedSizes: {
small: { w: 3, h: 6 }, small: { w: 3, h: 6 },
@@ -111,7 +115,7 @@ export const WIDGET_REGISTRY = {
category: "Daily Volcanic Activity", category: "Daily Volcanic Activity",
description: "List of explosion events over a specified time period.", description: "List of explosion events over a specified time period.",
component: lazy(() => import('../components/widgets/ExplosionEvents.jsx')), component: lazy(() => import('../components/Widgets/ExplosionEvents.jsx')),
// The specific settings UI for this widget // The specific settings UI for this widget
settingsComponent: lazy(() => import('../components/widgets/settings/ExplosionEventsSettings.jsx')), settingsComponent: lazy(() => import('../components/widgets/settings/ExplosionEventsSettings.jsx')),
@@ -131,8 +135,8 @@ export const WIDGET_REGISTRY = {
name: "Plume Events", name: "Plume Events",
category: "Daily Volcanic Activity", category: "Daily Volcanic Activity",
description: "Plot of explosion heights over a weeks time.", description: "Plot of explosion heights over a weeks time.",
component: lazy(() => import('../components/widgets/PlumeEvents.jsx')), component: lazy(() => import('../components/Widgets/PlumeEvents.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/PlumeEventsSettings.jsx')), settingsComponent: lazy(() => import('../components/Widgets/settings/PlumeEventsSettings.jsx')),
defaultSettings: { defaultSettings: {
startDate: getStartDate(), startDate: getStartDate(),
endDate: getEndDate(), endDate: getEndDate(),
@@ -146,8 +150,8 @@ export const WIDGET_REGISTRY = {
name: "Seismic Events", name: "Seismic Events",
category: "Daily Volcanic Activity", category: "Daily Volcanic Activity",
description: "Plot of number of seismic events over a weeks time.", description: "Plot of number of seismic events over a weeks time.",
component: lazy(() => import('../components/widgets/SeismicEvents.jsx')), component: lazy(() => import('../components/Widgets/SeismicEvents.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/SeismicEventsSettings.jsx')), settingsComponent: lazy(() => import('../components/Widgets/settings/SeismicEventsSettings.jsx')),
defaultSettings: { defaultSettings: {
startDate: getStartDate(), startDate: getStartDate(),
endDate: getEndDate(), endDate: getEndDate(),
@@ -161,7 +165,7 @@ export const WIDGET_REGISTRY = {
name: "Alert Level Changes Quad Plot", name: "Alert Level Changes Quad Plot",
category: "Computer Science", category: "Computer Science",
description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.", description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.",
component: lazy(() => import('../components/widgets/LlmAlChangesQuadPlot.jsx')), component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
allowedSizes: { allowedSizes: {
medium: { w: 3, h: 12 } medium: { w: 3, h: 12 }
} }
@@ -171,8 +175,8 @@ export const WIDGET_REGISTRY = {
name: "Communication Coordinator Report", name: "Communication Coordinator Report",
category: "Daily Volcanic Activity", category: "Daily Volcanic Activity",
description: "Generated Report", description: "Generated Report",
component: lazy(() => import('../components/widgets/CommCoorReport.jsx')), component: lazy(() => import('../components/Widgets/CommCoorReport.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/CommCoorReportSettings.jsx')), settingsComponent: lazy(() => import('../components/Widgets/settings/CommCoorReportSettings.jsx')),
defaultSettings: { defaultSettings: {
copyToClipboard: false, copyToClipboard: false,
downloadReport: false, downloadReport: false,
@@ -180,7 +184,7 @@ export const WIDGET_REGISTRY = {
endDate: getEndDate(), endDate: getEndDate(),
}, },
allowedSizes: { allowedSizes: {
medium: { w: 3, h: 12 }, large: { w: 3, h: 12 },
} }
}, },
@@ -188,8 +192,8 @@ export const WIDGET_REGISTRY = {
name: "VAA Polygon Projections Map", name: "VAA Polygon Projections Map",
category: "Maps", 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", 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')), component: lazy(() => import('../components/Widgets/VaaMap.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/VaaMapSettings.jsx')), settingsComponent: lazy(() => import('../components/Widgets/settings/VaaMapSettings.jsx')),
defaultSettings: { defaultSettings: {
position: [45.61142, -122.49630], // CVO's coordinates position: [45.61142, -122.49630], // CVO's coordinates
startDate: getToday(), startDate: getToday(),
@@ -209,7 +213,7 @@ export const WIDGET_REGISTRY = {
name: "Travel Gantt Chart", name: "Travel Gantt Chart",
category: "Communications", category: "Communications",
description: "View upcoming travels in a gantt chart", description: "View upcoming travels in a gantt chart",
component: lazy(() => import('../components/widgets/TravelGanttChart.jsx')), component: lazy(() => import('../components/Widgets/TravelGanttChart.jsx')),
allowedSizes: { allowedSizes: {
medium: { w: 6, h: 12 } medium: { w: 6, h: 12 }
} }
+4 -2
View File
@@ -65,7 +65,7 @@ const Login = () => {
return ( return (
<Flex p={8} bg="gray.50" _dark={{ bg: "gray.900" }} flex="1" minH="100vh" align="center" justify="center"> <Flex p={8} bg="gray.50" _dark={{ bg: "gray.900" }} flex="1" minH="100vh" align="center" justify="center">
<Box w="full" maxW="450px" bg="white" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px" borderColor="gray.200"> <Box w="full" maxW="450px" bg="white" borderColor="gray.200" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px">
<Stack gap={6} align="stretch" as="form" onSubmit={handleSubmit}> <Stack gap={6} align="stretch" as="form" onSubmit={handleSubmit}>
<Stack gap={2} textAlign="center"> <Stack gap={2} textAlign="center">
@@ -88,6 +88,7 @@ const Login = () => {
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="jdoe" placeholder="jdoe"
{...fieldStyles} {...fieldStyles}
color="gray.800"
/> />
</Field.Root> </Field.Root>
@@ -102,8 +103,9 @@ const Login = () => {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" // placeholder="••••••••" // not a necessary placeholder
{...fieldStyles} {...fieldStyles}
color="gray.800"
/> />
</Field.Root> </Field.Root>
+14 -17
View File
@@ -29,11 +29,14 @@ export default function MyDashboard() {
const hasHydrated = useRef(false); const hasHydrated = useRef(false);
// 1. Pull the global slot recipes // 1. Pull the global slot recipes
const recipe = useSlotRecipe({key: "glassPanel"}); const widgetRecipe = useSlotRecipe({ key: "widget" });
const styles = recipe({ layout: "widget" }); const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
const popoverStyles = recipe({ layout: "popover" });
// 2. Populate widgets // 2. Pass the editing state directly into the widget recipe!
const widgetStyles = widgetRecipe({ mode: isEditing ? "editing" : undefined });
const popoverStyles = popoverRecipe();
// 3. Populate widgets
useEffect(() => { useEffect(() => {
if (user?.preferences?.dashboard_layout && !hasHydrated.current) { if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
const savedLayout = user.preferences.dashboard_layout; const savedLayout = user.preferences.dashboard_layout;
@@ -55,7 +58,7 @@ export default function MyDashboard() {
isResizable: false // The magic lock! No resizing allowed globally. isResizable: false // The magic lock! No resizing allowed globally.
})); }));
// 3. The component mapper // 4. The component mapper
const renderWidget = (widget) => { const renderWidget = (widget) => {
const registryEntry = WIDGET_REGISTRY[widget.type]; const registryEntry = WIDGET_REGISTRY[widget.type];
@@ -67,7 +70,7 @@ export default function MyDashboard() {
return ( return (
<> <>
<Box css={styles.header}> <Box css={widgetStyles.header}>
<Text> <Text>
{registryEntry.name} {registryEntry.name}
</Text> </Text>
@@ -83,7 +86,7 @@ export default function MyDashboard() {
{/* NEW: The Positioner lifts the content out of the Flexbox flow */} {/* NEW: The Positioner lifts the content out of the Flexbox flow */}
<Popover.Positioner zIndex={1}> <Popover.Positioner zIndex={1}>
<Popover.Content css={popoverStyles.root}> <Popover.Content css={popoverStyles.content}>
<Suspense fallback={<Spinner size="sm"/>}> <Suspense fallback={<Spinner size="sm"/>}>
<SettingsUI <SettingsUI
widgetId={widget.id} widgetId={widget.id}
@@ -96,7 +99,7 @@ export default function MyDashboard() {
)} )}
</Box> </Box>
<Box css={styles.body}> <Box css={widgetStyles.body}>
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/> <Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
</Box> </Box>
</> </>
@@ -105,7 +108,7 @@ export default function MyDashboard() {
// Fallback if someone asks for a widget that doesn't exist // Fallback if someone asks for a widget that doesn't exist
return ( return (
<Flex css={styles.body} align="center" justify="center" direction="column"> <Flex css={widgetStyles.body} align="center" justify="center" direction="column">
<Text fontWeight="bold" color="red.400">Error</Text> <Text fontWeight="bold" color="red.400">Error</Text>
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text> <Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
</Flex> </Flex>
@@ -129,13 +132,7 @@ export default function MyDashboard() {
{widgets.map(widget => ( {widgets.map(widget => (
<Box <Box
key={widget.id} key={widget.id}
css={styles.root} css={widgetStyles.root}
// Grid-specific state overrides remain inline
boxShadow={isEditing ? "outline" : styles.root.boxShadow}
borderStyle={isEditing ? "dashed" : "solid"}
borderWidth={isEditing ? "2px" : "1px"}
borderColor={isEditing ? "orange.300" : styles.root.borderColor}
cursor={isEditing ? "grab" : "default"}
position="relative" // REQUIRED so the absolute button stays inside this box position="relative" // REQUIRED so the absolute button stays inside this box
> >
{/* THE DELETE BUTTON */} {/* THE DELETE BUTTON */}
@@ -163,7 +160,7 @@ export default function MyDashboard() {
{/* SUSPENSE BOUNDARY wrapping the render function */} {/* SUSPENSE BOUNDARY wrapping the render function */}
<Suspense fallback={ <Suspense fallback={
<Flex css={styles.body} align="center" justify="center"> <Flex css={widgetStyles.body} align="center" justify="center">
<Spinner size="md" color="blue.400" thickness="3px"/> <Spinner size="md" color="blue.400" thickness="3px"/>
</Flex> </Flex>
}> }>
-5
View File
@@ -46,11 +46,6 @@ const useAuthStore = create((set) => ({
// Hit the new lightweight endpoint // Hit the new lightweight endpoint
const res = await api.get('session/'); const res = await api.get('session/');
// 🔨 Security Fix a broken API call, Verify we actually got a user object back, not HTML fallback text
if (typeof res.data === 'string' || !res.data.username) {
throw new Error("Received invalid data format instead of user session");
}
// Rehydrate the store with the user's info! // Rehydrate the store with the user's info!
set({ set({
isAuthenticated: true, isAuthenticated: true,
+238 -398
View File
@@ -1,11 +1,25 @@
import {defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe} from "@chakra-ui/react"; import {defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe} from "@chakra-ui/react";
// CONSOLIDATED ✔ // REUSABLE GLASS SURFACE
const glassSurface = {
bg: {base: "rgb(253 253 253 / 0.4)", _dark: "rgb(37 38 46 / 0.8)"},
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid",
borderColor: {base: "gray.200", _dark: "whiteAlpha.200"},
boxShadow: "sm",
};
/* ==========================================================================
RECIPES
========================================================================== */
// 1. Header
const headerRecipe = defineSlotRecipe({ const headerRecipe = defineSlotRecipe({
slots: ["root", "search_bar", "user_info"], slots: ["root", "search_bar", "user_info"],
base: { base: {
root: { root: {
bg: "{transparent}", bg: "transparent",
color: "text", color: "text",
width: "100%", width: "100%",
height: "100%", height: "100%",
@@ -20,7 +34,7 @@ const headerRecipe = defineSlotRecipe({
// input element in search bar component // input element in search bar component
"& input": { "& input": {
width: "410px", width: "410px",
} },
}, },
user_info: { user_info: {
flexDirection: "row", flexDirection: "row",
@@ -32,21 +46,29 @@ const headerRecipe = defineSlotRecipe({
// The Text component // The Text component
"& p": { "& p": {
fontWeight: "600", fontWeight: "600",
fontSize: "md" // equivalent to 16px fontSize: "md",
} },
} },
}, },
}); });
// CONSOLIDATED ✔ // 2. Sidebar
const sidebarRecipe = defineSlotRecipe({ const sidebarRecipe = defineSlotRecipe({
slots: ["root", "logo", "logo_text", "footer", "button_text", "buttons", "collapse_button", "color_mode_button"], slots: [
"root",
"logo",
"logo_text",
"footer",
"button_text",
"buttons",
"collapse_button",
"color_mode_button",
],
base: { base: {
root: { root: {
// 1. Semi-transparent backgrounds // 1. Semi-transparent backgrounds
bg: { bg: {
// base: "linear-gradient(to bottom right, #99000040 20%, #FF660040 60%, #EEEE0040 100%)", // this is too pale for light mode imo base: "linear-gradient(to bottom right, #97282880 20%, #EB7F3160 60%, #FCAD3840 100%)",
base: "linear-gradient(to bottom right, #97282880 20%, #EB7F3160 60%, #FCAD3840 100%)", // colors from: https://colorhunt.co/palette/972828e45742eb7f31fcad38
_dark: "linear-gradient(to bottom right, #9900003C 20%, #FF66003C 60%, #FFC0003C 100%)" _dark: "linear-gradient(to bottom right, #9900003C 20%, #FF66003C 60%, #FFC0003C 100%)"
}, },
@@ -62,8 +84,6 @@ const sidebarRecipe = defineSlotRecipe({
borderRight: "none", borderRight: "none",
borderRadius: "2xl", borderRadius: "2xl",
// boxShadow: "0 4px 30px rgba(0, 0, 0, 0.1)",
// boxShadow: "-10px 4px 30px rgba(0, 0, 0, 0.1)",
width: "100%", width: "100%",
height: "calc(100% - 16px)", height: "calc(100% - 16px)",
margin: "8px", margin: "8px",
@@ -92,18 +112,18 @@ const sidebarRecipe = defineSlotRecipe({
"& svg": { "& svg": {
transition: "stroke-width 0.4s ease", transition: "stroke-width 0.4s ease",
flexShrink: "0" flexShrink: "0",
}, },
_hover: { _hover: {
bg: "rgba(255, 255, 255, 0.2)", bg: "rgba(255, 255, 255, 0.2)",
color: "{text}", color: "{text}",
"& svg": { "& svg": {
strokeWidth: "3" strokeWidth: "3",
}, },
"& span": { "& span": {
fontWeight: "700", fontWeight: "700",
} },
}, },
fontSize: "l", fontSize: "l",
@@ -122,12 +142,8 @@ const sidebarRecipe = defineSlotRecipe({
_hover: { _hover: {
bg: "{MainBackground}", bg: "{MainBackground}",
color: "{textInverted}", color: "{textInverted}",
"& svg": { "& svg": { strokeWidth: "2" },
strokeWidth: "2" // Keeps the active icon at normal thickness on hover "& span": { fontWeight: "500" },
},
"& span": {
fontWeight: "500",
}
}, },
// The Top Inverted Curve // The Top Inverted Curve
@@ -156,7 +172,7 @@ const sidebarRecipe = defineSlotRecipe({
WebkitMaskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)", WebkitMaskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
maskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)", maskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
pointerEvents: "none", pointerEvents: "none",
} },
}, },
}, },
logo: { logo: {
@@ -197,30 +213,8 @@ const sidebarRecipe = defineSlotRecipe({
_hover: { _hover: {
bg: "transparent", bg: "transparent",
}, },
} },
} },
// opened_theme_button: {
// width: "100%",
// align: "center",
// justifyContent: "right",
// mr: 6,
// // "& button": {
// // alignSelf: "center",
// // transition: "opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease",
// // _hover: {
// // bg: "transparent",
// // }
// // }
// },
// closed_theme_button: {
// // ml: 0.9,
// // "& button": {
// // transition: "opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease",
// // _hover: {
// // bg: "transparent",
// // }
// // }
// },
}, },
variants: { variants: {
state: { state: {
@@ -250,7 +244,7 @@ const sidebarRecipe = defineSlotRecipe({
} }
}); });
// CONSOLIDATED ✔ // 3. Dashboard Shell
const dashboardRecipe = defineRecipe({ const dashboardRecipe = defineRecipe({
base: { base: {
height: "100vh", height: "100vh",
@@ -265,7 +259,7 @@ const dashboardRecipe = defineRecipe({
} }
}); });
// CONSOLIDATED ✔ // 4. Home Page Tabs
const homeRecipe = defineSlotRecipe({ const homeRecipe = defineSlotRecipe({
slots: ["root", "header", "body"], slots: ["root", "header", "body"],
base: { base: {
@@ -290,305 +284,52 @@ const homeRecipe = defineSlotRecipe({
"& > div": { "& > div": {
alignItems: "center", alignItems: "center",
gap: 2, gap: 2,
},
// icon in each tab button
"& svg": {
// TODO: bring over svg styling
}
}
}, },
body: { body: {
flex: 1, flex: 1,
overflow: "auto", overflow: "auto",
} },
}, },
}) });
// const canvasRecipe = defineRecipe({ // 5. Dashboard Widget Card
// base: { const widgetRecipe = defineSlotRecipe({
// bg: "{transparent}",
// zIndex: "-1",
// color: "{text}"
// }
// });
// const widgetRecipe = defineSlotRecipe({
// slots: ["root", "header", "canvas"],
// base: {
// root: {
// flexDirection: "column",
// gap: "1",
// position: "relative",
// bg: "base100",
// borderRadius: "md",
// p: "1",
// paddingBottom: "2",
// border: "1px solid",
// borderColor: "{base300}",
// textAlign: "center",
// height: "100%",
// // position: "fixed"
// },
// header: {
// flexShrink: "0",
// bg: "{base300}",
// color: "{text}",
// cursor: "move",
// borderRadius: "sm",
// whiteSpace: "nowrap",
// textAlign: "left",
// pl: "2"
// },
// canvas: {
// flex: "1 1 auto",
// bg: "transparent",
// color: "{text}",
// cursor: "default",
// borderRadius: "md",
// overflow: "auto",
// }
// }
// });
// USED IN COMPONENTS:
// * Settings Button
// * My Dashboard
const glassPanelRecipe = defineSlotRecipe({
slots: ["root", "header", "body"], slots: ["root", "header", "body"],
// 🧊 THE GLASS MATH (Applies to everything)
base: { base: {
root: { root: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
position: "relative", position: "relative",
...glassSurface,
width: "100%",
height: "100%",
borderRadius: "3xl",
overflow: "hidden", overflow: "hidden",
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "rgba(255, 255, 255, 0.04)"},
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid",
borderColor: {base: "gray.200", _dark: "whiteAlpha.200"},
boxShadow: "sm",
}
},
// 📏 THE LAYOUTS (Applies selectively)
variants: {
layout: {
widget: {
root: {
width: "100%",
height: "100%",
borderRadius: "3xl",
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
px: 3,
pt: 2,
pb: 0,
"& > p": {
color: "titleColor",
fontWeight: "500",
fontSize: "16px",
// the following three specifications are equivalent to Chakra's 'truncate' prop
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "clip",
},
"& > button": {
borderRadius: "xl",
_hover: {
color: "white",
bg: "gray.500",
},
}
},
body: {
flex: "1",
overflow: "hidden",
position: "relative",
px: 1,
pb: 1,
pt: 0
}
},
dropdown: {
root: {
borderRadius: "xl",
p: 1
}
// (Header and body slots are ignored here because dropdowns don't use them)
},
popover: {
root: {
borderRadius: "2xl",
p: 0,
boxShadow: "xl",
minW: "240px",
maxW: "60%",
minH: "80%",
overflow: "hidden",
// OVERRIDES: Make the background 85-90% opaque instead of 40%
bg: {
base: "rgba(255, 255, 255, 0.75)",
_dark: "rgba(20, 22, 25, 0.8)"
},
// OVERRIDES: Increase the blur just to smooth out any stubborn gridlines
backdropFilter: "blur(15px)",
WebkitBackdropFilter: "blur(15px)",
// IMPORTANT: everything below is specific to the widget menu found in the settingsButton component
// dialog header
"& > div.chakra-dialog__header": {
bg: "transparent",
color: {
base: "gray.800",
_dark: "white"
},
py: 4,
borderColor: {
base: "gray.200",
_dark: "whiteAlpha.200"
},
// title
"& h2": {
fontSize: "xl",
fontWeight: "bold"
},
},
// dialog body
"& > div.chakra-dialog__body": {
p: 0,
// box container of the widget category menu
"& div.chakra-tabs__root > div:first-child": {
position: "relative",
display: "flex",
maxW: "100%",
mx: 5,
mt: 4,
mb: 6
},
// the horizontal widget categories menu
"& div.chakra-tabs__list": {
bg: {
base: "rgba(0, 0, 0, 0.04)",
_dark: "rgba(255, 255, 255, 0.05)"
},
p: 1,
borderRadius: "full",
display: "flex",
flexWrap: "nowrap",
overflowX: "auto",
maxW: "100%",
border: "1px solid",
borderColor: {
base: "blackAlpha.50",
_dark: "whiteAlpha.100"
},
scrollbarWidth: "none",
"&::-webkit-scrollbar": {display: "none"},
// the pill shaped menu options
"& button.chakra-tabs__trigger": {
px: 5,
py: 2,
borderRadius: "full",
whiteSpace: "nowrap",
flexShrink: 0,
color: {
base: "gray.500",
_dark: "gray.400"
},
fontWeight: "medium",
transition: "background-color 0.2s, color 0.2s, box-shadow 0.2s",
_hover: {
color: {
base: "gray.800",
_dark: "gray.200"
},
},
_selected: {
bg: {
base: "white",
_dark: "rgba(255, 255, 255, 0.12)"
},
color: {
base: "blue.600",
_dark: "blue.200"
},
boxShadow: "sm",
fontWeight: "bold",
}
}
},
// base styling applied to both chevron navigators
"& div.chakra-tabs__root > div:first-child div:has(svg)": {
position: "absolute",
top: "0",
bottom: "0",
w: "50px",
alignItems: "center",
justifyContent: "center",
zIndex: 2,
cursor: "pointer",
bg: {
base: "rgba(255, 255, 255, 0.7)",
_dark: "rgba(30, 30, 30, 0.8)"
},
color: {
base: "gray.600",
_dark: "gray.300"
},
backdropFilter: "blur(1px)",
transition: "background-color 0.2s",
_hover: {
color: {
base: "blue.500",
_dark: "blue.300"
},
bg: {
base: "rgba(255, 255, 255, 0.9)",
_dark: "rgba(40, 40, 40, 0.95)"
},
},
"&:has(.lucide-chevron-left)": {
left: 0,
borderTopLeftRadius: "full",
borderBottomLeftRadius: "full",
// Bites a transparent circle out of the flat right side!
WebkitMaskImage: "radial-gradient(circle at 100% 50%, transparent 30px, black 19px)",
maskImage: "radial-gradient(circle at 100% 50%, transparent 30px, black 35px)",
},
"&:has(.lucide-chevron-right)": {
right: 0,
borderTopRightRadius: "full",
borderBottomRightRadius: "full",
// THE MAGIC: Bites a transparent circle out of the flat left side!
WebkitMaskImage: "radial-gradient(circle at 0% 50%, transparent 30px, black 19px)",
maskImage: "radial-gradient(circle at 0% 50%, transparent 30px, black 35px)",
}
}
}
}
}
}, },
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
px: 3,
pt: 2,
pb: 0,
"& > p": {
color: "titleColor",
fontWeight: "500",
fontSize: "16px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "clip",
},
"& > button": {
borderRadius: "xl",
_hover: {color: "white", bg: "gray.500"},
},
},
body: {flex: "1", overflow: "hidden", position: "relative", px: 1, pb: 1, pt: 0},
},
variants: {
mode: { mode: {
editing: { editing: {
root: { root: {
@@ -597,16 +338,116 @@ const glassPanelRecipe = defineSlotRecipe({
borderWidth: "2px", borderWidth: "2px",
borderColor: "orange.300", borderColor: "orange.300",
cursor: "grab", cursor: "grab",
} },
} },
} },
}, },
defaultVariants: {
layout: "widget"
}
}); });
// 6. TRUE POPOVER: Compact floating card for 3-dots menus
const glassPopoverRecipe = defineSlotRecipe({
slots: ["content", "arrow", "positioner"],
base: {
content: {
...glassSurface,
borderRadius: "xl",
p: 3,
boxShadow: "lg",
bg: {base: "rgba(255, 255, 255, 0.95)", _dark: "#353742"},
backdropFilter: "blur(15px)",
WebkitBackdropFilter: "blur(15px)",
},
},
});
// 7. MODAL DIALOG: Large view for the Widget Shop
const glassDialogRecipe = defineSlotRecipe({
slots: ["content", "header", "body", "backdrop"],
base: {
backdrop: {
bg: "rgba(0, 0, 0, 0.4)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
},
content: {
display: "flex",
flexDirection: "column",
position: "relative",
...glassSurface,
borderRadius: "2xl",
p: 2,
boxShadow: "xl",
minW: "240px",
maxW: "60%",
minH: "80%",
overflow: "hidden",
bg: {base: "rgba(255, 255, 255, 0.75)", _dark: "rgb(22 23 28 / 0.9)"},
backdropFilter: "blur(15px)",
WebkitBackdropFilter: "blur(15px)",
},
header: {
bg: "transparent",
color: {base: "gray.800", _dark: "white"},
py: 4,
borderColor: {base: "gray.200", _dark: "whiteAlpha.200"},
"& h2": {fontSize: "xl", fontWeight: "bold"},
},
body: {
p: 0,
"& div.chakra-tabs__root > div:first-child": {
position: "relative",
display: "flex",
maxW: "100%",
mx: 5,
mt: 4,
mb: 6
},
"& div.chakra-tabs__list": {
bg: {base: "rgba(0, 0, 0, 0.04)", _dark: "rgba(255, 255, 255, 0.05)"},
p: 1, borderRadius: "full", display: "flex", flexWrap: "nowrap", overflowX: "auto", maxW: "100%",
border: "1px solid", borderColor: {base: "blackAlpha.50", _dark: "whiteAlpha.100"},
scrollbarWidth: "none", "&::-webkit-scrollbar": {display: "none"},
"& button.chakra-tabs__trigger": {
px: 5, py: 2, borderRadius: "full", whiteSpace: "nowrap", flexShrink: 0,
color: {base: "gray.500", _dark: "gray.400"},
fontWeight: "medium", transition: "background-color 0.2s, color 0.2s, box-shadow 0.2s",
_hover: {color: {base: "gray.800", _dark: "gray.200"}},
_selected: {
bg: {base: "white", _dark: "rgba(255, 255, 255, 0.12)"},
color: {base: "blue.600", _dark: "blue.200"},
boxShadow: "sm",
fontWeight: "bold"
}
}
},
"& div.chakra-tabs__root > div:first-child div:has(svg)": {
position: "absolute", top: "0", bottom: "0", w: "50px", alignItems: "center", justifyContent: "center",
zIndex: 2, cursor: "pointer", bg: {base: "rgba(255, 255, 255, 0.7)", _dark: "rgba(30, 30, 30, 0.8)"},
color: {base: "gray.600", _dark: "gray.300"}, backdropFilter: "blur(1px)", transition: "background-color 0.2s",
_hover: {
color: {base: "blue.500", _dark: "blue.300"},
bg: {base: "rgba(255, 255, 255, 0.9)", _dark: "rgba(40, 40, 40, 0.95)"}
},
"&:has(.lucide-chevron-left)": {
left: 0,
borderTopLeftRadius: "full",
borderBottomLeftRadius: "full",
WebkitMaskImage: "radial-gradient(circle at 100% 50%, transparent 30px, black 19px)",
maskImage: "radial-gradient(circle at 100% 50%, transparent 30px, black 35px)"
},
"&:has(.lucide-chevron-right)": {
right: 0,
borderTopRightRadius: "full",
borderBottomRightRadius: "full",
WebkitMaskImage: "radial-gradient(circle at 0% 50%, transparent 30px, black 19px)",
maskImage: "radial-gradient(circle at 0% 50%, transparent 30px, black 35px)"
},
},
},
},
});
// 6. Pill/Glass Expanding Tab Button
const glassTabRecipe = defineRecipe({ const glassTabRecipe = defineRecipe({
base: { base: {
display: "flex", display: "flex",
@@ -630,7 +471,7 @@ const glassTabRecipe = defineRecipe({
// --- THE GLASS EFFECT (Inactive) --- // --- THE GLASS EFFECT (Inactive) ---
// Uses a highly transparent white/black depending on mode // Uses a highly transparent white/black depending on mode
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "rgba(255, 255, 255, 0.03)"}, bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "#1e2027"},
backdropFilter: "blur(12px)", backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)",
border: "1px solid", border: "1px solid",
@@ -644,7 +485,7 @@ const glassTabRecipe = defineRecipe({
pr: "16px", pr: "16px",
// --- THE GLASS EFFECT (Hover) --- // --- THE GLASS EFFECT (Hover) ---
// Becomes slightly more opaque when you mouse over // Becomes slightly more opaque when you mouse over
bg: {base: "rgba(255, 255, 255, 0.8)", _dark: "rgba(255, 255, 255, 0.1)"}, bg: {base: "rgba(255, 255, 255, 0.8)", _dark: "#191a22"},
borderColor: {base: "rgba(0, 0, 0, 0.1)", _dark: "rgba(255, 255, 255, 0.15)"}, borderColor: {base: "rgba(0, 0, 0, 0.1)", _dark: "rgba(255, 255, 255, 0.15)"},
color: "fg", color: "fg",
textDecoration: "none", textDecoration: "none",
@@ -663,29 +504,46 @@ const glassTabRecipe = defineRecipe({
bg: "{gooeyButtonBackground}", bg: "{gooeyButtonBackground}",
cursor: "default", cursor: "default",
textDecoration: "none", textDecoration: "none",
} },
} },
} },
}); });
/* ==========================================================================
RAW WIDGET COLORS (For Canvas/Plotly libraries that reject CSS variables)
========================================================================== */
export const rawWidgetColors = {
light: {
text: "#000000",
muted: "#4A5568", // gray.600
grid: "rgba(0, 0, 0, 0.1)",
},
dark: {
text: "#FFFFFF",
muted: "#A0AEC0", // gray.400
grid: "rgba(255, 255, 255, 0.15)",
}
};
/* ==========================================================================
CONFIG & SYSTEM
========================================================================== */
const config = defineConfig({ const config = defineConfig({
globalCss: { globalCss: {
// the shadow box behind widgets when editing layout
".react-grid-placeholder": { ".react-grid-placeholder": {
background: "#c0d2e3ff !important", background: "#c0d2e3ff !important",
borderColor: "#a9b6c2ff !important", borderColor: "#a9b6c2ff !important",
boxShadow: "0 0 10px #c0d2e3ff !important", boxShadow: "0 0 10px #c0d2e3ff !important",
opacity: "1 !important", opacity: "1 !important",
borderRadius: "5px", borderRadius: "3xl",
_dark: { _dark: {
background: "#393c3fff !important", background: "#3B3D49FF !important",
borderColor: "#000000ff !important", borderColor: "#000000ff !important",
boxShadow: "0 0 10px #16181bff !important", boxShadow: "0 0 10px #16181bff !important",
opacity: "1 !important", opacity: "1 !important",
}, },
}, },
".react-resizable-handle-se": {
zIndex: "1",
},
"*": { "*": {
scrollbarWidth: "thin", scrollbarWidth: "thin",
scrollbarColor: "#a0aec0 transparent", scrollbarColor: "#a0aec0 transparent",
@@ -706,59 +564,43 @@ const config = defineConfig({
"::-webkit-scrollbar-track": { "::-webkit-scrollbar-track": {
background: "transparent", background: "transparent",
}, },
// '.leaflet-tile': { // this is what changes the maps colors to 'dark mode'
// _dark: { ".leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-attribution, .leaflet-container": {
// filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
// }
// },
'.leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-attribution, .leaflet-container': {
_dark: { _dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)", filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
} },
}, },
'.leaflet-control-attribution': { ".leaflet-container": {
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
},
'.leaflet-container': {
fontFamily: "system-ui, sans-serif !important", fontFamily: "system-ui, sans-serif !important",
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
}, },
// ".leaflet-popup-content": { // removes the animation of moving widgets, seems very harsh and jumpy w/o animation
// fontFamily: "georgia, system-ui, sans-serif !important" // ".react-grid-item": {
// transition: "none !important"
// }, // },
// ".leaflet-control a": {
// fontFamily: "georgia, system-ui, sans-serif !important"
// },
// ".leaflet-tooltip": {
// fontFamily: "georgia, system-ui, sans-serif !important"
// },
".react-grid-item": {
transition: "none !important"
},
}, },
theme: { theme: {
semanticTokens: { semanticTokens: {
colors: { colors: {
// base100: {value: {base: "#f6f9faff", _dark: "#1617199e"}}, text: { value: { base: "#131315", _dark: "rgb(234 234 234 / 0.94)" } },
// base200: {value: {base: "#e6ecf1ff", _dark: "#242424"}}, textInverted: { value: { base: "#000000", _dark: "#FFFFFF" } }, // Fixed: Properly inverted
// base300: {value: {base: "#d3dae4ff", _dark: "#0a0b0bff"}}, MainBackground: { value: { base: "#f1f1f1", _dark: "#16171c" } },
// primary: {value: {base: "#2B98F8", _dark: "#2B98F8"}}, titleColor: { value: { base: "colors.gray.800", _dark: "#e1e1e1" } },
// primaryLt: {value: {base: "#98cdffff", _dark: "#98cdffff"}}, iconColor: { value: { base: "#191919", _dark: "rgb(255 255 255 / 0.82)" } }, // Fixed: Visible in dark mode
// secondaryBG: {value: {base: "#EDF1F1", _dark: "#121212"}}, glassSeperator: { value: { base: "#0000001A", _dark: "#FFFFFF3F" } },
// border: {value: {base: "#000000", _dark: "#cbcbcb84"}}, gooeyButtonBackground: {value: {base: "rgba(255, 255, 255, 0.1)", _dark: "#333540"}},
text: {value: {base: "#000000", _dark: "#FFFFFF"}},
textInverted: {value: {base: "#000000", _dark: "#FFFFFF"}},
// icon: {value: {base: "#000000", _dark: "#FFFFFF"}},
gooeyButtonBackground: {value: {base: "rgba(255, 255, 255, 0.1)", _dark: "rgba(255, 255, 255, 0.1)"}}, // WIDGET SPECIFIC VALUES
MainBackground: {value: {base: "#F9F9F9", _dark: "#141414"}}, widgetItemBg: { value: { base: "rgb(115 132 166 / 0.17)", _dark: "rgb(51 53 64)" } },
titleColor: {value: {base: "gray.800", _dark: "#FFFFFF"}}, widgetItemExpandedBg: { value: { base: "rgb(184 197 220 / 0.17)", _dark: "rgb(59 61 73)" } },
iconColor: {value: {base: "gray.600", _dark: "#000000"}}, widgetBadgeBg: { value: { base: "rgb(154 163 179 / 0.45)", _dark: "rgb(75 77 90)" } },
glassSeperator: {value: {base: "#0000001A", _dark: "#FFFFFF3F"}}, widgetInnerBadgeBg: { value: { base: "rgb(154 163 179 / 0.45)", _dark: "rgb(100 103 119)" } },
widgetTextMuted: { value: { base: "#262627", _dark: "rgb(236 236 236 / 0.9)" } },
// ALERT COLORS
alertRed: { value: { base: "colors.red.600", _dark: "colors.red.400" } },
alertOrange: { value: { base: "colors.orange.600", _dark: "colors.orange.400" } },
alertYellow: { value: { base: "colors.yellow.600", _dark: "colors.yellow.400" } },
alertGreen: { value: { base: "colors.green.600", _dark: "colors.green.400" } },
}, },
}, },
tokens: { tokens: {
@@ -768,19 +610,17 @@ const config = defineConfig({
}, },
recipes: { recipes: {
dashboard: dashboardRecipe, dashboard: dashboardRecipe,
// canvas: canvasRecipe, glassTab: glassTabRecipe,
glassTab: glassTabRecipe
}, },
slotRecipes: { slotRecipes: {
header: headerRecipe, header: headerRecipe,
sidebar: sidebarRecipe, sidebar: sidebarRecipe,
// widget: widgetRecipe,
glassPanel: glassPanelRecipe,
home: homeRecipe, home: homeRecipe,
widget: widgetRecipe,
glassPopover: glassPopoverRecipe,
glassDialog: glassDialogRecipe,
}, },
}, },
}); });
// Define semantic tokens and export recipes
export const system = createSystem(defaultConfig, config); export const system = createSystem(defaultConfig, config);