Revert "Merge branch 'feature/gas-filter' into 'main'"

This commit is contained in:
Dan Hansen
2026-01-30 19:11:11 +00:00
parent 2a4139ab31
commit 946dbb499e
51 changed files with 174 additions and 4999 deletions
+37 -2468
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -18,14 +18,12 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.539.0",
"next-themes": "^0.4.6",
"plotly.js": "^3.3.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-grid-layout": "^1.5.2",
"react-hook-form": "^7.62.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-plotly.js": "^2.6.0",
"react-resizable": "^3.0.5",
"tailwindcss": "^4.1.11",
"uuid": "^11.1.0"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

@@ -1,14 +1,11 @@
import Widget from "./Widget";
import { Stack, Box, Field, Input, Button, ButtonGroup, Text, Flex } from "@chakra-ui/react";
import { Stack, Box, Field, Input, Button, Text, Flex } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { LuAsterisk } from "react-icons/lu";
import { CancelButton } from "./CancelButton.jsx";
export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
const { register, handleSubmit, reset, formState: {errors} } = useForm();
const onSubmit = (data) => {
console.log("data: ", data);
if (data.volcanoname === '') {
@@ -17,10 +14,6 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
setIsSubmit(true);
reset();
}
const onCancel = () => {
reset();
setIsSubmit(false);
}
const [isSubmit, setIsSubmit] = useState(false);
// Prop Forwarding
@@ -39,7 +32,7 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
pr={3}
display="flex"
flexDir="column"
gap={12}
gap="10"
>
<Field.Root orientation="horizontal">
<Stack direction="column" gap="3" width="100%">
@@ -63,12 +56,7 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
</Flex>
</Stack>
</Field.Root>
<Flex justify ="flex-end" gap = {2}>
<ButtonGroup size="sm" variant="solid">
<Button type="submit">Submit</Button>
<CancelButton onCancel ={onCancel}/>
</ButtonGroup>
</Flex>
<Button type="submit" size="sm">Submit</Button>
</Box>
):(
// DISPLAYING ALERT LEVEL DATA
@@ -2,6 +2,6 @@ import { Button } from "@chakra-ui/react";
export function CancelButton ({onCancel}) {
return (
<Button onClick={onCancel} type={"button"}>Cancel</Button>
<Button onClick={onCancel} borderRadius="md">Cancel</Button>
)
}
@@ -1,7 +1,7 @@
import { Dialog, Portal, CloseButton, Button } from "@chakra-ui/react";
export default function DialogPopup({dialog}) {
//console.log("3. dialog popup")
console.log("3. dialog popup")
return(
<Dialog.RootProvider value={dialog}>
<Portal>
@@ -1,7 +1,7 @@
import { Box, Menu, Portal } from '@chakra-ui/react'
import WidgetMenu from './WidgetMenu';
export default function FirstWidgetButton({ view, appendWidget }) {
export default function FirstWidgetButton({ appendWidget }) {
const triggerItem = (<>
<Menu.Trigger asChild>
<Box
@@ -16,11 +16,10 @@ export default function FirstWidgetButton({ view, appendWidget }) {
boxShadow="md"
border="2px dashed"
borderColor="gray.300"
bg={{base:"white",_dark:"gray"}}
color={{base:"black",_dark:"white"}}
bg="gray.50"
cursor="pointer"
_hover = {{
bg: {base:"gray.100", _dark:"gray.600"},
bg: "gray.100",
borderColor: "gray.400",
boxShadow: "lg",
}}
@@ -30,7 +29,7 @@ export default function FirstWidgetButton({ view, appendWidget }) {
</Menu.Trigger>
</>);
const positioning = { placement: "bottom-middle" };
const WidgetMenuProps = { view, appendWidget, triggerItem, positioning };
const WidgetMenuProps = { appendWidget, triggerItem, positioning };
return (
<WidgetMenu {...WidgetMenuProps} />
);
@@ -1,232 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
Box, Button, Checkbox, CheckboxGroup, Field, HStack,
Input, Spinner, Stack, Text }
from "@chakra-ui/react";
// DEBUG
console.log({
Field,
Checkbox,
CheckboxGroup,
});
async function fetchJson(url, { signal } = {}) {
const res = await fetch(url, { signal });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${body || res.statusText}`);
}
return res.json();
}
function buildGasQuery(filters) {
const params = new URLSearchParams();
// Multi-select fields ~ Add more once working
for (const v of filters.volcanoname ?? []) params.append("volcanoname", v);
for (const c of filters.compoundname ?? []) params.append("compoundname", c);
for (const s of filters.sitename ?? []) params.append("sitename", s);
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
return params.toString();
}
/**
* Filters a list of values using a case-insensitive substring search.
*
* If `searchText` is empty, null, or undefined, the original list is returned.
* The function is defensive against null/undefined inputs and will never throw.
*
* @param {Array<any> | null | undefined} list
* The list of items to filter. Items are converted to strings before
* comparison. If null or undefined, an empty array is returned.
*
* @param {string | null | undefined} searchText
* The text to search for within each list item. Leading/trailing whitespace
* is ignored and matching is case-insensitive.
*/
function filterList(list, searchText) {
const q = (searchText ?? "").trim().toLowerCase();
if (!q) return list ?? [];
return (list ?? []).filter((x) => String(x).toLowerCase().includes(q));
}
export default function GasFilter({ apiBase, onChange }) {
const [options, setOptions] = useState(null);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(true);
// filter state
const [filters, setFilters] = useState({
volcanoname: [],
compoundname: [],
sitename: [],
date_from: "",
date_to: "",
});
// local search inputs
const [volcanoSearch, setVolcanoSearch] = useState("");
const [compoundSearch, setCompoundSearch] = useState("");
useEffect(() => {
const ac = new AbortController();
setLoading(true);
setErr(null);
// TODO - Add options end point to populate dropdowns
fetchJson(`${apiBase}/options`, { signal: ac.signal })
.then(setOptions)
.catch((e) => e.name !== "AbortError" && setErr(e))
.finally(() => setLoading(false));
return () => ac.abort();
}, [apiBase]);
// Emit query string whenever filters change
useEffect(() => {
const qs = buildGasQuery(filters);
onChange?.(qs, filters);
}, [filters, onChange]);
const volcanoList = useMemo(
() => filterList(options?.volcanoname, volcanoSearch),
[options, volcanoSearch]
);
const compoundList = useMemo(
() => filterList(options?.compoundname, compoundSearch),
[options, compoundSearch]
);
if (loading) {
return (
<HStack p={2}>
<Spinner size="sm" />
<Text fontSize="sm">Loading Filters</Text>
</HStack>
);
}
if (err) {
return (
<Box p={2}>
<Text fontWeight="bold">Couldn't load filter options</Text>
<Text fontSize="sm" opacity={0.8}>
{String(err.message ?? err)}
</Text>
</Box>
);
}
return (
<Box p={2} borderWidth="1px" borderRadius="md">
<HStack justify="space-between" mb={2}>
<Text fontWeight="bold">Gas Filters</Text>
<Button
size="sm"
variant="outline"
onClick={() =>
setFilters({
volcanoname: [],
compoundname: [],
sitename: [],
date_from: "",
date_to: "",
})
}
>
Clear
</Button>
</HStack>
<Stack spacing={4}>
{/*Volcano multi-select*/}
<Field.Root>
<Field.Label fontSize="sm">Volcano</Field.Label>
<Input
size="sm"
placeholder="Search Volcano"
value={volcanoSearch}
onChange={(e) => setVolcanoSearch(e.target.value)}
mb={2}
/>
<CheckboxGroup
value={filters.volcanoname}
onChange={(vals) =>
setFilters((f) => ({ ...f, volcanoname: vals }))
}
>
<Stack maxH="140px" overflowY="auto" spacing={1}>
{volcanoList.map((v) => (
<Checkbox.Root key={v} value={v}>
{v}
</Checkbox.Root>
))}
</Stack>
</CheckboxGroup>
</Field.Root>
{/* Compound multi-select */}
<Field.Root>
<Field.Label fontSize="sm">Compound</Field.Label>
<Input
size="sm"
placeholder="Search Compound"
value={compoundSearch}
onChange={(e) => setCompoundSearch(e.target.value)}
mb={2}
/>
<CheckboxGroup
value={filters.compoundname}
onChange={(vals) =>
setFilters((f) => ({...f, compoundname: vals}))
}
>
<Stack maxH="140px" overflowY="auto" spacing={1}>
{compoundList.map((c) => (
<Checkbox.Root key={c} value={c}>
{c}
</Checkbox.Root>
))}
</Stack>
</CheckboxGroup>
</Field.Root>
{/* Date Range */}
<HStack align="start" spacing={3}>
<Field.Root>
<Field.Label fontSize="sm">From</Field.Label>
<Input
size="sm"
type="date"
value={filters.date_from}
min={options?.date_min ?? undefined}
max={options?.date_max ?? undefined}
onChange={(e) =>
setFilters((f) => ({...f, date_from: e.target.value }))
}
/>
</Field.Root>
<Field.Root>
<Field.Label fontSize="sm">To</Field.Label>
<Input
size="sm"
type="date"
value={filters.date_to}
min={options?.date_min ?? undefined}
max={options?.date_max ?? undefined}
onChange={(e) =>
setFilters((f) => ({...f, date_to: e.target.value }))
}
/>
</Field.Root>
</HStack>
</Stack>
</Box>
);
}
@@ -1,264 +0,0 @@
/**
* Gas Measurements Widget
*
* Renders a Plotly time-series chart of gas measurement values returned from the API.
* Fetches rows from the backend, normalizes them into plottable points, groups points by compound,
* and displays one trace per compound.
*/
import { useEffect, useMemo, useState } from "react";
import Plot from "react-plotly.js";
import { Box, Center, Spinner, Text } from "@chakra-ui/react";
import Widget from "./Widget";
// import GasFilter from "./GasFilter";
const API_BASE = "http://localhost:8000"; // Possibly import.meta.env.VITE_API_BASE_URL
/**
* Fetches JSON from a URL and throws a helpful error if the response is not OK.
*
* @param {string} url URL to request
* @param {object} [options] Optional options object
* @param {AbortSignal} [options.signal] Abort signal used to cancel the request
* @returns {Promise<any>} Parsed JSON response body
* @throws {Error} If the HTTP response is not OK (non-2xx)
*/
async function fetchJson(url, { signal } = {}) {
const res = await fetch(url, { signal });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${body || res.statusText}`);
}
return res.json();
}
/**
* Safely converts an input value into a finite number; otherwise returns null.
* Useful for filtering out non-numeric measurement values before plotting.
*
* @param {any} value Input value to check/convert
* @param {any} [rawvalue] Optional original/raw value (currently unused)
* @returns {number|null} Finite numeric value, or null if invalid
*/
function toNumberOrNull(value, rawvalue) {
if (typeof value === "number" && Number.isFinite(value))
return value;
else
return null;
}
/**
* Groups an array of items into a Map based on the provided key function.
*
* @template T
* @param {T[]} arr Array of items to group
* @param {(item: T) => any} keyFn Function that returns the grouping key for an item
* @returns {Map<any, T[]>} Map from key -> array of items for that key
*/
function groupBy(arr, keyFn) {
const m = new Map();
for (const item of arr) {
const k = keyFn(item);
if (!m.has(k)) m.set(k, []);
m.get(k).push(item);
}
return m;
}
/**
* Normalizes raw API rows into Plotly-friendly point objects.
* Filters out rows missing a valid numeric value or sample date, and sorts by date ascending.
*
* @param {Array<object>} rows Raw rows returned by the API
* @returns {Array<object>} Normalized points with x/y and helpful metadata for hover tooltips
*/
function normalizeRowsToPoints(rows) {
const points = [];
for (const r of rows ?? []) {
const y = toNumberOrNull(r.value);
if (y === null) continue;
if (!r.sampledate) continue;
points.push({
x: r.sampledate,
y,
compound: r.compoundname ?? "unknown",
volcanoname: r.volcanoname ?? "",
sitename: r.sitename ?? "",
meta: r,
});
}
points.sort((a, b) => (a.x < b.x ? -1 : a.x > b.x ? 1 : 0));
return points;
}
/**
* GasWidget React component.
*
* Fetches gas measurement rows from the backend and renders a multi-trace Plotly time series.
* Each trace represents a different compound (e.g., SO2, CO2), drawn as lines+markers.
*
* @param {object} props Component props
* @param {string} props.id Widget instance id (used by the Widget wrapper)
* @param {boolean} props.isEditable Whether the dashboard is in edit mode
* @param {boolean} props.isDelete Whether the dashboard is in delete mode
* @param {(id: string) => void} props.DeleteWidget Callback to delete this widget
* @returns {JSX.Element} Rendered widget UI
*/
export default function GasWidget({ id, config, isEditable, isDelete, DeleteWidget }) {
const [rows, setRows] = useState([]);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const volcano = config?.volcano_name ?? null;
const site = config?.site_name ?? null;
const compounds = Array.isArray(config?.compound_name) ? config.compound_name: [];
const start_date = config?.start_date ?? null;
const end_date = config?.end_date ?? null;
const compoundsKey = useMemo(() => compounds.slice().sort().join("|"), [compounds]);
const endpoint = useMemo(() => {
const params = new URLSearchParams();
params.set("include_measurements", "true");
params.set("include_personnel", "false");
// Supported /search filters
if (volcano) params.set("volcano_name", volcano);
if (site) params.set("site_name", site);
for (const c of compounds) params.append("compound_name", c);
if (start_date) params.set("start_date", start_date);
if (end_date) params.set("end_date", end_date);
return `${API_BASE}/search/?${params.toString()}`;
}, [volcano, site, compoundsKey, start_date, end_date]);
// DEBUG
useEffect(() => {
console.log("[GasWidget] endpoint:", endpoint)
}, [endpoint]);
useEffect(() => {
console.log("[GasWidget] volcano_name:", volcano);
}, [volcano]);
/**
* Fetch rows on mount (and when the endpoint changes).
* Uses AbortController to cancel in-flight requests on unmount or dependency change.
*/
useEffect(() => {
const ac = new AbortController();
setIsLoading(true);
setError(null);
fetchJson(endpoint, { signal: ac.signal })
.then(setRows)
.catch((e) => e.name !== "AbortError" && setError(e))
.finally(() => setIsLoading(false));
return () => ac.abort();
}, [endpoint]);
/**
* Normalized point data derived from raw rows.
* Memoized to avoid recomputation unless rows change.
*/
const points = useMemo(() => normalizeRowsToPoints(rows), [rows]);
/**
* Plotly trace data (one trace per compound).
* Groups points by compound name and creates a scatter trace with lines+markers.
*/
const plotData = useMemo(() => {
const byCompound = groupBy(points, (p) => p.compound);
const traces = [];
for (const [compound, pts] of byCompound.entries()) {
traces.push({
x: pts.map((p) => p.x),
y: pts.map((p) => p.y),
name: compound,
type: "scatter",
mode: "lines+markers",
text: pts.map((p) => `${p.volcanoname} - ${p.sitename}`),
hovertemplate:
"Date: %{x}<br>Value: %{y}<br>%{text}<extra>" + compound + "</extra>",
});
}
traces.sort((a, b) => a.name.localeCompare(b.name));
return traces;
}, [points]);
/**
* Plotly layout configuration for axis, margins, and legend placement.
* Memoized once since it is static.
*/
const layout = useMemo(
() => ({
// title: { text: "Gas Measurements" }, // This is redundant
autosize: true,
margin: { l: 55, r: 20, t: 20, b: 30 },
xaxis: { title: "Sample Date", type: "date" },
yaxis: { title: "Value" },
legend: {
orientation: "h",
x: 0,
y: -0.25, // push legend below plot area
xanchor: "left",
yanchor: "top",
font: { size: 10 },
itemwidth: 80, // helps wrap/pack items tighter
},
}),
[]
);
return (
<Widget
title={`${volcano} - Gas Compound Measurements`}
id={id}
isEditable={isEditable}
isDelete={isDelete}
DeleteWidget={DeleteWidget}
>
<Box height="100%" width="100%" p={2}>
{/* <GasFilter
apiBase={API_BASE}
onChange={(qs) => setQueryString(qs)}
/> */}
{isLoading && (
<Center height="100%">
<Spinner />
</Center>
)}
{!isLoading && error && (
<Center height="100%" flexDir="column" gap={2}>
<Text fontWeight="bold">Couldn't load data</Text>
<Text fontSize="sm" opacity={0.8}>
{String(error.message ?? error)}
</Text>
</Center>
)}
{!isLoading && !error && points.length === 0 && (
<Center height="100%">
<Text opacity={0.8}>No numeric values found</Text>
</Center>
)}
{!isLoading && !error && points.length > 0 && (
<Box height="100%" width="100%">
<Plot
data={plotData}
layout={layout}
config={{ responsive: true, displayModeBar: false }}
style={{ width: "100%", height: "100%" }}
/>
</Box>
)}
</Box>
</Widget>
);
}
@@ -38,7 +38,7 @@ export default function MapButtonControls({settingsBtn, position = "topright"})
return () => {
control.remove();
};
}, [map, position]);
}, [map, position, settingsBtn ]);
return container ? createPortal(
settingsBtn,
@@ -1,130 +0,0 @@
import { Box, Checkbox, CheckboxGroup, Separator , chakra, CloseButton, Text, IconButton, Accordion, RadioGroup, } from "@chakra-ui/react";
import { useState } from "react";
import { CiFilter } from "react-icons/ci";
function GenerateFilters ({config, selected, setFilterValue, toggleInArray}){
return (
<Accordion.Root collapsible defaultValue={[]}>
{config.map((f) => {
if (f.type === "checkbox") {
const current = Array.isArray(selected?.[f.id]) ? selected[f.id] : [];
return (
<Accordion.Item key={f.id} value={f.id}>
<Accordion.ItemTrigger>
<Accordion.ItemIndicator />
<Text fontWeight="semibold">
{f.label}
{current.length ? ` (${current.length})` : ""}
</Text>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Box display="flex" flexDir="column" gap="2" pt="2">
{f.options.map((opt) => {
const checked = current.includes(opt.value);
return (
<Checkbox.Root
key={opt.value}
checked={checked}
onCheckedChange={() => toggleInArray(f.id, opt.value)}
>
<Checkbox.HiddenInput />
<Checkbox.Control />
<Checkbox.Label>{opt.label}</Checkbox.Label>
</Checkbox.Root>
);
})}
</Box>
</Accordion.ItemContent>
</Accordion.Item>
);
}
if (f.type === "radio") {
const current = selected?.[f.id] ?? "";
return (
<Accordion.Item key={f.id} value={f.id}>
<Accordion.ItemTrigger>
<Accordion.ItemIndicator />
<Text fontWeight="semibold">
{f.label}
{current ? ` (${current})` : ""}
</Text>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Box pt="2">
<RadioGroup.Root
value={current}
onValueChange={(details) => setFilterValue(f.id, details.value)}
>
<Box display="flex" flexDir="column" gap="2">
{f.options.map((opt) => (
<RadioGroup.Item key={opt.value} value={opt.value}>
<RadioGroup.ItemHiddenInput />
<RadioGroup.ItemIndicator />
<RadioGroup.ItemText>{opt.label}</RadioGroup.ItemText>
</RadioGroup.Item>
))}
</Box>
</RadioGroup.Root>
</Box>
</Accordion.ItemContent>
</Accordion.Item>
);
}
return null;
})}
</Accordion.Root>
);
}
function Header(props) {
const { setOpen } = props;
return (
<chakra.div display="flex" dir="row" alignItems="center" justifyContent="space-between" pl="12">
<Text textStyle="lg">Filters</Text>
<CloseButton size="sm" colorPalette='red' onClick={() => setOpen(false)}/>
</chakra.div>
);
}
export default function MapFilter({config, selected, setFilterValue, toggleInArray}) {
const [open, setOpen] = useState(false);
return open ? (
<Box
width="240px"
display="flex"
flexDir="column"
gap="2"
p="2"
bg="white"
borderRadius="5px"
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
>
<Header {...{ open, setOpen }} />
<Separator />
<GenerateFilters config={config}
selected={selected}
setFilterValue={setFilterValue}
toggleInArray={toggleInArray}
/>
</Box>
) : (
<IconButton
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
size="sm"
variant="subtle"
onClick={() => setOpen(true)}
>
<CiFilter />
</IconButton>
);
}
@@ -1,49 +0,0 @@
import {Menu, MenuTrigger, MenuContent, MenuPositioner, Portal, chakra, Text, CloseButton, Box, Separator} from "@chakra-ui/react";
import { IconButton } from "@chakra-ui/react";
import {useState} from "react";
import { IoIosInformationCircleOutline } from "react-icons/io";
function Header(props) {
const { setOpen } = props;
return (
<chakra.div display="flex" dir="row" alignItems="center" justifyContent="space-between" pl="12">
<Text textStyle="lg">Legend</Text>
<CloseButton size="sm" colorPalette='red' onClick={() => setOpen(false)}/>
</chakra.div>
);
}
export default function MapLegend({legend}) {
const [open, setOpen] = useState(false);
return open ? (
<Box
width="240px"
display="flex"
flexDir="column"
gap="2"
p="2"
bg="white"
borderRadius="5px"
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
>
<Header {...{ open, setOpen }} />
{legend}
<Separator />
</Box>
) : (
<IconButton
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
size="sm"
variant="subtle"
onClick={() => setOpen(true)}
>
<IoIosInformationCircleOutline />
</IconButton>
);
}
@@ -1,4 +1,4 @@
import { Box, Checkbox, Separator , CloseButton, Text, Field, chakra, IconButton, Icon, Input, Fieldset, Flex } from "@chakra-ui/react";
import { Box, Checkbox, Separator , CloseButton, Text, chakra, Field, IconButton, Icon, Input, Fieldset, Flex } from "@chakra-ui/react";
import { useState } from "react";
import { FiSettings } from "react-icons/fi";
import { IoIosArrowDown } from "react-icons/io";
@@ -1,33 +0,0 @@
import Widget from "src/components/Canvas/CanvasComponents/Widget.jsx";
import { Box, Text } from "@chakra-ui/react";
export default function PlaceholderWidget({
title = "Widget",
id,
isEditable,
isDelete,
DeleteWidget
}) { return (
<Widget
title={title}
id={id}
isEditable={isEditable}
isDelete={isDelete}
DeleteWidget={DeleteWidget}
>
<Box
h="100%"
w="100%"
display="flex"
alignItems="center"
justifyContent="center"
opacity={0.6}
textAlign="center"
>
<Text fontSize="sm">
{title} widget coming soon
</Text>
</Box>
</Widget>
);
}
@@ -3,16 +3,9 @@ import { IoIosArrowBack } from "react-icons/io";
import { FiSettings } from "react-icons/fi";
import WidgetMenu from "./WidgetMenu";
export function SettingsButton({ view, onEditWidgets, appendWidget}) {
const triggerItem = (
<>
<Menu.TriggerItem>
<IoIosArrowBack /> Add Widget
</Menu.TriggerItem>
</>);
const WidgetMenuProps = { view, appendWidget, triggerItem }
export function SettingsButton({ onEditWidgets, appendWidget}) {
const triggerItem = (<> <Menu.TriggerItem> <IoIosArrowBack /> Add Widget</Menu.TriggerItem> </>);
const WidgetMenuProps = { appendWidget, triggerItem }
return (
<Menu.Root>
<Menu.Trigger asChild >
@@ -20,12 +13,11 @@ export function SettingsButton({ view, onEditWidgets, appendWidget}) {
<FiSettings />
</IconButton>
</Menu.Trigger>
<Portal>
<Menu.Positioner>
<Menu.Content>
<WidgetMenu {...WidgetMenuProps}/>
<Menu.Item value="edit" onClick={onEditWidgets} >
<Menu.Item value="eidt" onClick={onEditWidgets} >
Edit Widgets
</Menu.Item>
</Menu.Content>
@@ -1,529 +0,0 @@
import {
Box,
CloseButton,
Text,
Input,
Button,
Stack,
HStack,
Select,
RadioGroup,
Checkbox,
CheckboxGroup,
Code,
Field,
Fieldset,
createListCollection,
} from "@chakra-ui/react";
import { useEffect, useMemo, useState } from "react";
import { WIDGET_INSPECTOR_REGISTRY } from "@/constants/widgetInspectorRegistry";
const API_BASE = "http://localhost:8000";
async function fetchJson(url, { signal } = {}) {
const res = await fetch(url, { signal });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${body || res.statusText}`);
}
return res.json();
}
function getByPath(obj, path) {
if (!obj || !path) return undefined;
return path.split(".").reduce((acc, k) => (acc == null ? acc : acc[k]), obj);
}
function buildQueryString(draft) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(draft)) {
if (value == null) continue;
// array (checkbox group)
if (Array.isArray(value)) {
if (value.length === 0) continue;
value.forEach(v => params.append(key, v));
continue;
}
// scalar
if (value === "") continue;
params.append(key, value);
}
return params.toString();
}
export default function WidgetInspector({
open,
onClose,
target,
widgetArray,
updateWidgetConfig
}) {
if (!open || !target) return null;
const { id, widgetKey } = target;
const widget = widgetArray.find(w => w.i === id);
const def = WIDGET_INSPECTOR_REGISTRY[widgetKey];
if (!widget || !def) return null;
// current saved config
const cfg = widget.config ?? {};
// local draft config (only applied on Submit)
const initialDraft = useMemo(() => {
const base = def?.defaultConfig ?? {};
return {...base, ...cfg,};
}, [def, cfg]);
const [draft, setDraft] = useState(initialDraft);
// reset draft when switching widgets or repoening
useEffect(() => setDraft(initialDraft), [initialDraft]);
// source payloads (e.g. { options: { volcanoname:[], ... } })
// const [options, setOptions] = useState({
// volcanoname: [],
// compoundname: [],
// sitename: [],
// date_min: null,
// date_max: null,
// });
const [sources, setSources] = useState({});
const [optError, setOptError] = useState(null);
const [optLoading, setOptLoading] = useState(false);
useEffect(() => {
if (!open) return;
const ac = new AbortController();
const srcDefs = def.optionSources ?? {};
const entries = Object.entries(srcDefs);
if (entries.length === 0) {
setSources({});
setOptLoading(false);
setOptError(null);
return;
}
setOptLoading(true);
setOptError(null);
Promise.all(
entries.map(async ([name, src]) => {
const url =
typeof src.url === "function"
? src.url({ draft })
: src.url;
// DEBUG
console.log("Fetching options source:", name, `${API_BASE}${url}`)
const data = await fetchJson(`${API_BASE}${url}`, { signal: ac.signal });
return [name, data];
})
)
.then((pairs) => setSources(Object.fromEntries(pairs)))
.catch((e) => e.name !== "AbortError" && setOptError(e))
.finally(() => setOptLoading(false));
return () => ac.abort();
}, [open, def, draft?.volcano_name]);
// DEBUG
useEffect(() => {
if (open) console.log("sources", sources);
}, [open, sources]);
const patchDraft = (partial) => setDraft((d) => ({ ...d, ...partial }));
const validationError = useMemo(() => {
for (const f of def.fields ?? []) {
if (typeof f.validate === "function") {
const msg = f.validate(draft, sources);
if (msg) return msg;
}
}
return null;
}, [def, draft, sources]);
const onSubmit = () => {
if (validationError) return;
const keys = def.submitKeys ?? Object.keys(def.defaultConfig ?? {});
const patch = {};
for (const k of keys) patch[k] = draft[k] ?? null;
// DEBUG
console.log("submit keys:", keys);
console.log("draft:", draft);
console.log("patch:", patch);
// Build and log query string for debugging
const queryString = buildQueryString(patch);
console.log("Query string:", `?${queryString}`);
// Full URL
console.log("Full URL:", `${API_BASE}/search?${queryString}`);
updateWidgetConfig(id, patch);
onClose();
};
const onReset = () => {
setDraft(def?.defaultConfig ?? {});
};
// --- Field renderers i.e. Radio, Checkbox, Select, Search ---
// 'Select' Field Handler
const renderField = (field) => {
if (field.type === "select") {
const srcObj = sources[field.optionsFrom?.source];
const raw = getByPath(srcObj, field.optionsFrom?.path) ?? [];
// DEV WARNING
if (process.env.NODE_ENV !== "production" && raw != null && !Array.isArray(raw)) {
console.warn(
`[WidgetInspector] optionsFrom path "${field.optionsFrom.path}" did not resolve to an array`,
{
widget: widgetKey,
field: field.key,
source: field.optionsFrom.source,
path: field.optionsFrom.path,
resolvedValue: raw,
}
);
}
const items = [
{ label: field.placeholder ?? "(all)", value: "" },
...raw.map((v) => ({ label: String(v), value: String(v) })),
];
const collection = createListCollection({ items });
const current = draft[field.key] ?? "";
const value = current ? [current] : []; // empty means (all)
return (
<Field.Root key={field.key}>
<Field.Label fontSize="sm">{field.label}</Field.Label>
<Select.Root
collection={collection}
value={value}
onValueChange={(e) => {
const next = e.value?.[0] ?? ""; // "" is "(all)"
const clears = field.clearOnChange ?? [];
const partial = { [field.key]: next || null };
for (const c of clears) partial[c] = null;
patchDraft(partial);
}}
disabled = { optLoading || items.length <= 1 } // only "(all)" exists
// size="sm"
// placeholder={optLoading ? "Loading…" : field.placeholder ?? "(all)"}
// value={draft[field.key] ?? ""}
// isDisabled={optLoading || opts.length === 0}
// onChange={(e) => {
// const v = e.target.value || null;
// // handle clears on change
// const clears = field.clearOnChange ?? [];
// const partial = { [field.key]: v };
// for (const c of clears) partial[c] = null;
// patchDraft(partial);
// }}
>
<Select.Trigger>
<Select.ValueText placeholder={optLoading ? "Loading..." : (field.placeholder ?? "(all)")} />
</Select.Trigger>
<Select.Content>
{items.map((it) => (
<Select.Item key={it.value} item={it}>
<Select.ItemText>{it.label}</Select.ItemText>
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
{/* {opts.map((x) => (
<option key={x} value={x}>
{x}
</option>
))} */}
</Select.Root>
</Field.Root>
);
}
// 'Radio Button' Field Handler
if (field.type === "radio") {
const srcObj = sources[field.optionsFrom?.source];
const raw = getByPath(srcObj, field.optionsFrom?.path);
if (process.env.NODE_ENV !== "production" && raw != null && !Array.isArray(raw)) {
console.warn(
`[WidgetInspector] optionsFrom path "${field.optionsFrom.path}" did not resolve to an array`,
{
widget: widgetKey,
field: field.key,
source: field.optionsFrom.source,
path: field.optionsFrom.path,
resolvedValue: raw,
}
);
}
const options = Array.isArray(raw) ? raw : [];
const isMulti = field.mode === "multiple";
const value = isMulti
? Array.isArray(draft[field.key]) ? draft[field.key] : []
: draft[field.key] ?? "";
return (
<Field.Root key={field.key}>
<Field.Label fontSize="sm">{field.label}</Field.Label>
<RadioGroup.Root
value={isMulti ? value : [value].filter(Boolean)}
onValueChange={(e) => {
if (isMulti) {
patchDraft({ [field.key]: e.value });
} else {
patchDraft({ [field.key]: e.value[0] ?? null });
}
}}
>
<Stack spacing={1}>
{options.map((opt) => (
<RadioGroup.Item key={opt} value={opt}>
<RadioGroup.ItemHiddenInput />
<RadioGroup.ItemIndicator />
<RadioGroup.ItemText>{opt}</RadioGroup.ItemText>
</RadioGroup.Item>
))}
</Stack>
</RadioGroup.Root>
</Field.Root>
);
}
// 'Checkbox' Field Handler
if (field.type === "checkbox") {
const srcObj = sources[field.optionsFrom?.source];
const raw = getByPath(srcObj, field.optionsFrom?.path);
if (process.env.NODE_ENV !== "production" && raw != null && !Array.isArray(raw)) {
console.warn(
`[WidgetInspector] optionsFrom path "${field.optionsFrom.path}" did not resolve to an array`,
{
widget: widgetKey,
field: field.key,
source: field.optionsFrom.source,
path: field.optionsFrom.path,
resolvedValue: raw,
}
);
}
// const options = Array.isArray(raw) ? raw.map(String) : [];
// items: [{ label, value }]
const items = (Array.isArray(raw) ? raw : []).map((x) => ({
label: String(x),
value: String(x),
}));
// const selected = Array.isArray(draft[field.key]) ? draft[field.key] : [];
const value = Array.isArray(draft[field.key]) ? draft[field.key].map(String) : [];
// Can be used to validate fields ~ optional
const invalid = typeof field.validate === "function" ? !!field.validate(draft, sources) : false;
return (
<Fieldset.Root key={field.key} invalid={invalid}>
<Fieldset.Legend fontSize="sm">{field.label}</Fieldset.Legend>
<CheckboxGroup
name={`wi-${widgetKey}-${field.key}`}
value={value}
onValueChange={(next) => {
console.log("group next:", next)
patchDraft({ [field.key]: next })
}}
>
<Stack
spacing={1}
maxH="160px"
overflow="auto"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p="2"
>
{items.map((item) => (
<Checkbox.Root key={item.value} value={item.value}>
<Checkbox.HiddenInput value={item.value} />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>{item.label}</Checkbox.Label>
</Checkbox.Root>
))}
</Stack>
</CheckboxGroup>
<HStack mt="1" justify="space-between">
<Fieldset.HelperText>{value.length} selected</Fieldset.HelperText>
<Button size="xs" variant="ghost" onClick={() => patchDraft({ [field.key]: [] })}>
Clear
</Button>
</HStack>
{invalid && field.errorText && (
<Fieldset.ErrorText>{field.errorText}</Fieldset.ErrorText>
)}
</Fieldset.Root>
);
}
// Date Range Handler
if (field.type === "dateRange") {
const srcObj = sources[field.minFrom?.source];
const min = getByPath(srcObj, field.minFrom?.path) ?? undefined;
const max = getByPath(srcObj, field.maxFrom?.path) ?? undefined;
const startKey = field.startKey;
const endKey = field.endKey;
return (
<Field.Root key={field.key}>
<Field.Label fontSize="sm">{field.label}</Field.Label>
<HStack>
<Input
size="sm"
type="date"
min={min}
max={max}
value={draft[startKey] ?? ""}
onChange={(e) => patchDraft({ [startKey]: e.target.value || null })}
/>
<Text fontSize="sm" opacity={0.7}>
to
</Text>
<Input
size="sm"
type="date"
min={min}
max={max}
value={draft[endKey] ?? ""}
onChange={(e) => patchDraft({ [endKey]: e.target.value || null })}
/>
</HStack>
{!!validationError && (
<Text mt="1" fontSize="xs" color="red.500">
{validationError}
</Text>
)}
<HStack mt="2" justify="flex-end">
{(field.presets ?? []).map((p) => {
const variant = p.variant ?? "outline";
return (
<Button
key={p.label}
size="xs"
variant={variant}
onClick={() => {
if (p.startFrom && p.endFrom) {
// use values from options payload (e.g. date_min/date_max)
const src = sources[field.minFrom?.source];
patchDraft({
[startKey]: src?.[p.startFrom] ?? null,
[endKey]: src?.[p.endFrom] ?? null,
});
return;
}
patchDraft({ [startKey]: p.start ?? null, [endKey]: p.end ?? null });
}}
isDisabled={p.startFrom && (!min || !max)}
>
{p.label}
</Button>
);
})}
</HStack>
</Field.Root>
);
}
// fallback (future types)
return null;
};
return (
<Box
position="fixed"
top="84px"
right="12px"
width="320px"
maxH="calc(100vh - 96px)"
overflow="auto"
bg="white"
borderRadius="5px"
p="2"
zIndex="overlay"
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
>
<Box display="flex" alignItems="center" justifyContent="space-between" px="2" py="1">
<Text textStyle="lg">{def.title ?? widgetKey}</Text>
<CloseButton size="sm" onClick={onClose} />
</Box>
{/* Options loading/error */}
<Box mt="2" px="2" fontSize="xs" opacity={0.8}>
{optLoading && <Text>Loading options</Text>}
{!optLoading && optError && (
<Text color="red.500">Options failed: {String(optError.message ?? optError)}</Text>
)}
</Box>
{/* Debug */}
{/* <Box mt="2" p="2" bg="gray.50" borderRadius="md" fontSize="xs" whiteSpace="pre-wrap">
<Text fontWeight="bold" mb="1">
Saved config
</Text>
{JSON.stringify(cfg ?? {}, null, 2)}
<Text fontWeight="bold" mt="2" mb="1">
Draft (pending)
</Text>
{JSON.stringify(draft ?? {}, null, 2)}
</Box> */}
{/* Fields */}
<Stack mt="3" spacing="3" p="2">
{(def.fields ?? []).map(renderField)}
{/* Actions */}
<HStack justify="flex-end">
<Button size="sm" variant="outline" onClick={onReset}>
Reset
</Button>
<Button
size="sm"
colorScheme="blue"
onClick={onSubmit}
isDisabled={optLoading || !!validationError}
>
Submit
</Button>
</HStack>
</Stack>
</Box>
);
}
@@ -1,25 +1,23 @@
import { Menu, Portal } from "@chakra-ui/react";
import { getWidgetsForView } from "@/constants/widgetRegistry";
// import AlertLevel from "./AlertLevelWidget";
export default function WidgetMenu({view, appendWidget, triggerItem, positioning}) {
const entries = getWidgetsForView(view);
import AlertLevel from "./AlertLevelWidget";
export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
return (
<Menu.Root positioning={positioning}>
{triggerItem}
<Portal>
<Menu.Positioner>
<Menu.Content>
{entries.map((w) => (
<Menu.Item
key={w.key}
value={w.key}
onClick={() => appendWidget(w.key)}
<Menu.Item
value="AlertLevel"
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
>
{w.label}
Alert Level
</Menu.Item>
))}
<Menu.Item value="CO2">CO2</Menu.Item>
<Menu.Item value="Ivans">IVANS</Menu.Item>
<Menu.Item value="PlumeHeights">Plume Heights</Menu.Item>
<Menu.Item value="AlertLevelChanges">Alert Level Changes</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
+14 -18
View File
@@ -1,6 +1,5 @@
import { Flex, Tabs, HStack } from '@chakra-ui/react';
import { MEDataEntry } from './AdminTabs/MEDataEntry';
import { NVEWS } from './AdminTabs/NVEWS'
import { ProjectCollector } from './AdminTabs/ProjectCollector';
import { NASApplication } from './AdminTabs/NASApplication';
import { IVANS } from './AdminTabs/IVANS'
@@ -31,7 +30,7 @@ export default function Admin({ view, onChangeView, onEditWidgets, appendWidget
as="header"
position="sticky"
top="0"
zIndex={2000} // zIndex: 1100
zIndex="dropdown" // zIndex: 1100
bg="base200"
pl={5}
pr={2}
@@ -43,28 +42,25 @@ export default function Admin({ view, onChangeView, onEditWidgets, appendWidget
>
{/* HEADER */}
<CanvasHeaderTabs tabs={ADMIN_TABS} />
{/*<HStack width="258px" justifyContent="flex-end">
<HStack width="258px" justifyContent="flex-end">
<SearchBar />
<SettingsButton />
</HStack>*/}
</HStack>
</Flex>
{/* CANVAS */}
<Tabs.Content value="medataentry">
<MEDataEntry />
</Tabs.Content>
<Tabs.Content value="nvews">
<NVEWS />
</Tabs.Content>
<Tabs.Content value="projectcollector">
<ProjectCollector />
</Tabs.Content>
<Tabs.Content value="nasapplication">
<NASApplication />
</Tabs.Content>
<Tabs.Content value="ivans">
<IVANS />
</Tabs.Content>
<MEDataEntry />
</Tabs.Content>
<Tabs.Content value="projectcollector">
<ProjectCollector />
</Tabs.Content>
<Tabs.Content value="nasapplication">
<NASApplication />
</Tabs.Content>
<Tabs.Content value="ivans">
<IVANS />
</Tabs.Content>
</Tabs.Root>
);
}
@@ -15,7 +15,7 @@ const yearItems = Array.from(
);
const years = createListCollection({
items: yearItems.reverse(),
items: yearItems.reverse(),
});
const quarters = createListCollection({
@@ -200,7 +200,7 @@ export function MEDataEntry() {
<Box m={4} mt={1} border="1px solid" borderRadius="sm" borderColor="gray.500">
<Box m={4} border="1px solid">
<Box pl={1} bg={{base:"gray.100", _dark:"gray.600"}} border="1px solid" borderColor="gray.300">
<Box pl={1} bg="gray.200" border="1px solid" borderColor="gray.400">
Number of people benefitting from VDAP activities, disaggregate by sex
</Box>
@@ -1,236 +0,0 @@
import React from "react";
import { Box, Text, Accordion, Table, Separator, Center,Button,Link} from "@chakra-ui/react";
import { useEffect, useState, useMemo } from "react";
import Map from "../NVEWSMap.jsx";
import {mapFilterConfig} from "@/utils/map-utils/map-configs/nvewsMapFilterConfig.js";
import { pctStyles } from "@/utils/pctStyles.js";
const SECTIONS = [
{
id: "volcano",
title: "Volcano",
fields:[
{label: "VNUM", key: "vnum"},
{label: "State", key: "volcano_subgroup"},
{label: "Threat Level", key: "nvews_threat_text"},
{label: "Observatory", key: "volcano_group"},
{label: "Network Completion %",key: "network_pct_complete"},
{label: "Network Area (km2)", key: "network_area_km2"},
{label: "Polygon dimensions (km x km or km if radius)", key: "r_or_p_dimensions_km"},
]
},
{
id: "seismic",
title: "Seismic",
fields:[
{label: "Stations", key: "seismic_station_total"},
{label: "Target", key: "seismic_target"},
{label: "% Complete", key: "seismic_pct_complete"},
]
},
{
id: "gnss",
title: "GNSS",
fields:[
{label: "Stations", key: "gnss_station_total"},
{label: "Target", key: "gnss_target"},
{label: "% Complete", key: "gnss_pct_complete"},
]
},
{
id: "cameras",
title: "Cameras",
fields:[
{label: "Stations", key: "camera_station_total"},
{label: "Target", key: "camera_target"},
{label: "% Complete", key: "camera_pct_complete"},
]
},
{
id: "infrasound",
title: "Infrasound",
fields:[
{label: "Stations", key: "infrasound_station_total"},
{label: "Target", key: "infrasound_target"},
{label: "% Complete", key: "infrasound_pct_complete"},
]
},
{
id: "tilt",
title: "Tilt",
fields:[
{label: "Stations", key: "tiltmeter_station_total"},
{label: "Target", key: "tiltmeter_target"},
{label: "% Complete", key: "tiltmeter_pct_complete"},
]
},
{
id: "real-time gas",
title: "Real-Time Gas",
fields:[
{label: "Stations", key: "gas_station_total"},
{label: "Target", key: "gas_target"},
{label: "% Complete", key: "gas_pct_complete"},
]
},
];
const COLUMNS = SECTIONS.flatMap(section =>
section.fields.map(field => ({
id: `${section.id}.${field.key}`,
sectionTitle: section.title,
label: field.label,
key: field.key,
isPercent: field.label.includes('%')
}))
);
async function fetchData(){
const res = await fetch("https://dev-vhptools.usgs.gov/vmidapi-free/reports/nvewsCompletionStatus");
if(!res.ok) throw new Error(res.statusText);
return res.json();
}
export function NVEWS() {
const origin = [45.61142478054226, -122.49630033167544];
const zoom = 3;
const type = ["https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",'&copy; OpenStreetMap contributors'];
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [selectedFilters, setSelectedFilters] = useState(() => {
const initial = {};
for (const f of mapFilterConfig) initial[f.id] = f.defaultValue;
return initial;
});
const [selectedVolcano, setSelectedVolcano] = useState(null);
function handleTableClick(v){
setSelectedVolcano([v.latitude, v.longitude]);
}
function setFilterValue(id, value) {
setSelectedFilters(prev => ({ ...prev, [id]: value }));
}
function toggleInArray(id, value) {
setSelectedFilters(prev => {
const arr = Array.isArray(prev[id]) ? prev[id] : [];
const next = arr.includes(value) ? arr.filter(x => x !== value) : [...arr, value];
return { ...prev, [id]: next };
});
}
function applyDataFilters(rows, config, selected) {
return rows.filter(row => {
return config.every(f => {
if (f.target?.kind !== "data") return true;
const sel = selected[f.id];
if (sel == null) return true;
if (Array.isArray(sel) && sel.length === 0) return true;
const value = row[f.target.field];
switch (f.target.op) {
case "in":
return Array.isArray(sel) ? sel.includes(value) : true;
case "eq":
return value === sel;
default:
return true;
}
});
});
}
useEffect(() => {
fetchData()
.then(setData)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
},[]);
if (error) return <pre style={{ color: "red" }}>{error}</pre>;
if (loading) return <p>Loading...</p>;
console.log("raw",data)
const filteredData = applyDataFilters(data ?? [], mapFilterConfig, selectedFilters);
console.log("filtered",filteredData);
return(
<Box width="100%" px={20} py={1} overflow="hidden">
<Box height="500px" width="100%" overflow="hidden">
<Map markers = {filteredData} origin ={origin} type={type} zoom = {zoom} mapFilters={mapFilterConfig}
selectedFilters={selectedFilters} setFilterValue={setFilterValue} toggleInArray={toggleInArray} selectedMarker={selectedVolcano} />
</Box>
{/*<Separator size ="lg" mt={4}/>*/}
<Center mt={4} bg="white">
<Text>
NVEWS Completion Status -
<Link href="https://dev-vhptools.usgs.gov/vmid-free/#/nvews-completion-status-report"
target="_blank" rel="noopener noreferrer" >
View Full Report
</Link>
</Text>
</Center>
<Box width="100%" maxHeight="500px">
<Table.ScrollArea maxHeight="500px" width="100%" overflowX="auto" overflowY = "auto">
<Table.Root showColumnBorder style={{minWidth: "max-content"}}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader position= "sticky" left = "0" bg = "white" zIndex="3" rowSpan={2}>Name</Table.ColumnHeader>
{SECTIONS.map(s => (
<Table.ColumnHeader key={s.id} colSpan={s.fields.length} textAlign="center">
{s.title}
</Table.ColumnHeader>
))}
</Table.Row>
<Table.Row>
{COLUMNS.map(c => (
<Table.ColumnHeader key={c.id}>{c.label}</Table.ColumnHeader>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{filteredData.map(v => (
<Table.Row key={v.vnum ?? v.volcano_name}>
<Table.Cell position= "sticky" left = "0" bg = "white" zIndex="2" cursor = "pointer" _hover = {{textDecoration: "underline"}}
onClick={() => handleTableClick(v)}>
{v.volcano_name}
</Table.Cell>
{COLUMNS.map(c => {
const value = v?.[c.key];
const pct = Number(String(value).replace("%",""));
return (
<Table.Cell key={c.id} {...(c.isPercent ? pctStyles(pct,100,70) : {})} onClick={() => handleTableClick(v)}
cursor="pointer" _hover={{textDecoration: "underline"}}>
{value ?? "-"}
</Table.Cell>
);
})}
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
</Box>
</Box>
)
}
@@ -3,23 +3,21 @@ import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import MapButtonControls from "../CanvasComponents/MapButtonControls";
import MapSettingsButton from "../CanvasComponents/MapSettingsButton";
export default function GlobalMap({markers = []}) {
const origin = [45.61142478054226, -122.49630033167544]; // [latitude, longitude]
export default function GlobalMap() {
const position = [45.61142478054226, -122.49630033167544]; // [latitude, longitude]
return (
<MapContainer center={origin} zoom={3} style={{ height: "100%", width: "100%" }}>
<MapContainer center={position} zoom={3} style={{ height: "100%", width: "100%" }}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; OpenStreetMap contributors'
/>
<MapButtonControls settingsBtn = {<MapSettingsButton />} position="topright" />
{markers.map(m => (
<Marker key={m.id} position = {m.position}>
<Popup>
{m.label}
</Popup>
</Marker>
))}
<Marker position={position}>
<Popup>
CVO Home of the volcanoligists
</Popup>
</Marker>
</MapContainer>
);
}
+24 -104
View File
@@ -1,12 +1,10 @@
import { useState } from "react";
import { HStack, Flex, Tabs, Box } from "@chakra-ui/react";
import { HStack, Flex, Tabs } from "@chakra-ui/react";
import MyDashboard from "./HomeTabs/MyDashboard";
import { SearchBar } from "../CanvasComponents/SearchBar";
import { SettingsButton } from "../CanvasComponents/SettingsButton";
import { DailyActivity } from "./HomeTabs/DailyActivity";
import { Gas } from "./HomeTabs/Gas";
import { Seismic } from "./HomeTabs/Seismic";
import { Deformation } from "./HomeTabs/Deformation";
import { RemoteSensing } from "./HomeTabs/RemoteSensing";
import { CancelButton } from "../CanvasComponents/CancelButton";
import { SaveButton } from "../CanvasComponents/SaveButton";
@@ -14,103 +12,44 @@ import DeleteAllButton from "../CanvasComponents/DeleteAllButton";
import { v4 as uuid } from 'uuid';
import { CanvasHeaderTabs } from '../CanvasComponents/CanvasHeaderTabs';
import { DASHBOARD_TABS } from "@/constants/viewKeys";
import { WIDGET_REGISTRY } from "@/constants/widgetRegistry";
import { WIDGET_INSPECTOR_REGISTRY } from "@/constants/widgetInspectorRegistry";
import WidgetInspector from "../CanvasComponents/WidgetInspector";
export default function Home(props) {
// deconstructing props
const { view, layout, setLayout, onLayoutChange, onChangeView, onCancel, isDelete, setIsDelete,
beginEditIfNeeded, isEditable, setIsEditable, widgetArray, setWidgetArray } = props;
const { view, layout, setLayout, setOriginalLayout, onLayoutChange, onChangeView, onCancel, setOriginalWidgets,
isDelete, setIsDelete, beginEditIfNeeded, isEditable, setIsEditable, widgetArray, setWidgetArray } = props;
const [inspectorOpen, setInspectorOpen] = useState(false);
const [inspectorTarget, setInspectorTarget] = useState(null);
const openInspector = ({ id, widgetKey }) => {
setInspectorTarget({ id, widgetKey, view });
setInspectorOpen(true);
};
function appendWidget(widgetKey, overrides = {}) {
function appendWidget(name, w, h, minw, minh, maxw, maxh) {
beginEditIfNeeded(); // Save state before anything else
// Place canvas in edit mode upon adding a new widget
setIsEditable(true);
setIsDelete(true);
const def = WIDGET_REGISTRY[widgetKey];
if(!def) {
console.warn("appendWidget: unknown widgetKey:", widgetKey);
return;
};
// Widget Inspector Configuration Settings
const inspectorDef = WIDGET_INSPECTOR_REGISTRY[widgetKey];
const defaultConfig = inspectorDef?.defaultConfig ?? {};
const newID = uuid(); // could be replaced with something else like guid
const COLS = 16;
const W = w ?? 3;
const H = h ?? 3;
if (widgetKey === "gas_measurements") {
setInspectorTarget({ view, id: newID, widgetKey });
setInspectorOpen(true);
}
const {
w = def.defaults.w,
h = def.defaults.h,
minW = def.defaults.minW,
minH = def.defaults.minH,
maxW = def.defaults.maxW,
maxH = def.defaults.maxH,
} = overrides;
// DEBUG
console.log("after add:", { widgetKey, layoutLen: layout.length, widgetLen: widgetArray.length });
// IMPORTANT: use functional updates so you don't rely on stale widgetArray/layout
setWidgetArray(prev => [
...prev,
{
i: newID,
widgetKey,
// config: JSON.parse(JSON.stringify(defaultConfig)), // Can use this if Safari complains
config: structuredClone(defaultConfig)
},
setWidgetArray([ // updating widget array containing info on the types of widgets
...widgetArray,
{i: newID, widget: name }
]);
// onAddWidget(newID, w, h, minw, minh, maxw, maxh); // updating array containing info on layout of widgets
setLayout(prev => [
...prev,
{
i: newID,
x: (prev.length * w) % COLS,
x: (prev.length * W) % COLS,
y: 0,
w,
h,
minW,
minH,
maxW,
maxH,
w: w,
h: h,
minW: minw,
minH: minh,
maxW: maxw,
maxH: maxh,
static: false,
resizeHandles: ['se']
},
]);
}
function updateWidgetConfig(id, patchOrFn) {
setWidgetArray(prev =>
prev.map(w => {
if (w.i !== id) return w;
const nextConfig =
typeof patchOrFn === "function"
? patchOrFn(w.config ?? {})
: {...(w.config ?? {}), ...(patchOrFn ?? {}) };
return { ...w, config: nextConfig };
})
);
}
const onSave = () => {
console.log("saving widgets...");
// Set all widgets back to static: true
@@ -122,6 +61,8 @@ export default function Home(props) {
);
setIsEditable(false);
setIsDelete(false);
setOriginalLayout([]); // Clear backup layout
setOriginalWidgets([]);
}
const onEditWidgets = () => {
@@ -156,11 +97,8 @@ export default function Home(props) {
}
// Prop Forwarding
const MyDashboardProps = { view, layout, onLayoutChange, isEditable,
beginEditIfNeeded, isDelete, DeleteWidget, widgetArray, appendWidget,
openInspector
};
const settingsButtonProps = { view, onEditWidgets, appendWidget };
const MyDashboardProps = { layout, onLayoutChange, isEditable, beginEditIfNeeded, isDelete, DeleteWidget, widgetArray, appendWidget };
const settingsButtonProps = { onEditWidgets, appendWidget };
return (
/* HEADER */
@@ -212,26 +150,11 @@ export default function Home(props) {
{/* CANVAS */}
<Tabs.Content value="mydashboard">
<Flex height="100%" width="100%" gap="3">
<Box flex="1" minW="0">
<MyDashboard {...MyDashboardProps}/>
</Box>
{inspectorOpen && inspectorTarget && (
<WidgetInspector
open={inspectorOpen}
onClose={() => setInspectorOpen(false)}
target={inspectorTarget}
widgetArray={widgetArray}
updateWidgetConfig={updateWidgetConfig}
/>
)}
</Flex>
</Tabs.Content>
<Tabs.Content value="gas">
<MyDashboard {...MyDashboardProps}/>
</Tabs.Content>
<Tabs.Content value="gas">
<Gas />
</Tabs.Content>
<Tabs.Content value="seismic">
<Seismic />
</Tabs.Content>
@@ -241,9 +164,6 @@ export default function Home(props) {
<Tabs.Content value="daily">
<DailyActivity />
</Tabs.Content>
<Tabs.Content value="deformation">
<Deformation />
</Tabs.Content>
</Tabs.Root>
);
}
@@ -1,22 +0,0 @@
import { Box, Text } from "@chakra-ui/react";
export function Deformation() {
return(
<Box p={8} mt={4} ml="auto" mr="auto" width="600px">
<Box
bg="bg"
shadow="md"
borderRadius="md"
p={8}
width="600px"
height="100px"
textAlign="center"
display="flex"
alignItems="center"
justifyContent="center"
>
<Text fontsize="2xl" fontWeight="bold" color="gray.700">Deformation dashboard coming soon.</Text>
</Box>
</Box>
);
}
@@ -2,13 +2,9 @@ import GridLayout, { WidthProvider } from 'react-grid-layout';
import { useSlotRecipe, chakra, Flex } from '@chakra-ui/react';
import CustomResizeHandle from '../../CanvasComponents/CustomResizeHandle';
import FirstWidgetButton from '../../CanvasComponents/FirstWidgetButton';
import PlaceholderWidget from '../../CanvasComponents/PlaceholderWidget';
import { WIDGET_REGISTRY, WIDGET_COMPONENTS } from '@/constants/widgetRegistry';
export default function MyDashboard({view, layout, onLayoutChange,
isEditable, isDelete, DeleteWidget, widgetArray, appendWidget, openInspector
}) {
//console.log("rendering MyDashboard");
export default function MyDashboard({layout, onLayoutChange, isEditable, isDelete, DeleteWidget, widgetArray, appendWidget}) {
console.log("rendering MyDashboard");
const recipe = useSlotRecipe({ key: "widget" });
const styles = recipe(); // using root recipe for widget below
const ResponsiveGridLayout = WidthProvider(GridLayout); // canvas width now adjusts to window size
@@ -16,8 +12,8 @@ export default function MyDashboard({view, layout, onLayoutChange,
const handleDragStop = () => { document.body.style.userSelect = ""; }; // allow text highlighting all other times
let merged = [];
//console.log("widgetArray: ", widgetArray);
//console.log("layout: ", layout);
console.log("widgetArray: ", widgetArray);
console.log("layout: ", layout);
if (widgetArray.length && layout.length) {
const widgetMap = new Map(widgetArray.map(item => [item.i, item]));
@@ -40,7 +36,7 @@ export default function MyDashboard({view, layout, onLayoutChange,
bg="gray.150"
borderRadius="md"
>
<FirstWidgetButton view={view} appendWidget={ appendWidget } />
<FirstWidgetButton appendWidget={ appendWidget } />
</Flex>
);
}
@@ -63,36 +59,11 @@ export default function MyDashboard({view, layout, onLayoutChange,
position="relative"
resizeHandle={<CustomResizeHandle isVisable={isEditable} />}
>
{merged.map((item) => {
const def = WIDGET_REGISTRY[item.widgetKey];
const isReal = def?.kind === "real";
const Widget = isReal
? WIDGET_COMPONENTS[item.widgetKey]
: PlaceholderWidget;
const title =
def?.PlaceholderTitle ??
def?.label ??
item.widgetKey ??
"Widget";
{ merged.map((item) => {
const Widget = item.widget;
return (
<Flex key={item.i} css={styles.root} boxShadow="md" height="100%" >
<Widget
title={!isReal ? title : undefined} // placeholder uses title prop
id={item.i}
config={item.config}
isEditable={isEditable}
isDelete={isDelete}
DeleteWidget={DeleteWidget}
appendWidget={appendWidget}
openInspector={
openInspector ? () => openInspector({ id: item.i, widgetKey: item.widgetKey })
: undefined
}
/>
<Widget id={item.i} isEditable={isEditable} isDelete={isDelete} DeleteWidget={DeleteWidget} appendWidget={appendWidget}/>
</Flex>
);
})
@@ -1,60 +0,0 @@
import {Box, Text, Flex, Image} from '@chakra-ui/react'
import volcanoDefault from "@/utils/map-utils/icons/green-triangle.svg";
import redVolcano from "@/utils/map-utils/icons/red-triangle.svg";
import yellowVolcano from "@/utils/map-utils/icons/yellow-triangle.svg";
import seismicDefault from "@/utils/map-utils/icons/triangle-black.svg";
import cameraDefault from "@/utils/map-utils/icons/camera.svg";
import gpsDefault from "@/utils/map-utils/icons/gps-solid.svg";
import soundIcon from "@/utils/map-utils/icons/sound-black.svg";
import tiltIcon from "@/utils/map-utils/icons/line-segment.svg";
import cloudIcon from "@/utils/map-utils/icons/cloud.svg";
export const NVEWSLegend = (
<Box display="flex" flexDir="column" gap="2">
<Flex align="center" gap="2">
<Image src={redVolcano} boxSize="18px" alt="red volcano"/>
<Text>070% Complete</Text>
</Flex>
<Flex align="center" gap="2">
<Image src={yellowVolcano} boxSize="18px" alt="yellow volcano"/>
<Text>70100% Complete</Text>
</Flex>
<Flex align="center" gap="2">
<Image src={volcanoDefault} boxSize="18px" alt="green volcano"/>
<Text>100% Complete</Text>
</Flex>
<Flex align="center" gap="2">
<Image src={seismicDefault} boxSize="18px" alt="green volcano"/>
<Text>Seismometer</Text>
</Flex>
<Flex align="center" gap="2">
<Image src={cameraDefault} boxSize="18px" alt="green volcano"/>
<Text>Camera</Text>
</Flex>
<Flex align="center" gap="2">
<Image src={gpsDefault} boxSize="18px" alt="green volcano"/>
<Text>GPS</Text>
</Flex>
<Flex align="center" gap="2">
<Image src={soundIcon} boxSize="18px" alt="green volcano"/>
<Text>Infrasound</Text>
</Flex>
<Flex align="center" gap="2">
<Image src={tiltIcon} boxSize="18px" alt="green volcano"/>
<Text>Tiltmeter</Text>
</Flex>
<Flex align="center" gap="2">
<Image src={cloudIcon} boxSize="18px" alt="green volcano"/>
<Text>Gas</Text>
</Flex>
</Box>
);
@@ -1,168 +0,0 @@
import React, {useState} from "react";
import { MapContainer, TileLayer, Marker, Popup, Circle, CircleMarker, Rectangle, Polygon,Polyline} from "react-leaflet";
import { Box, Text } from "@chakra-ui/react";
import MapButtonControls from "@/components/Canvas/CanvasComponents/MapButtonControls.jsx";
import MapFilter from "../CanvasComponents/MapFilter.jsx";
import MapLegend from "../CanvasComponents/MapLegend.jsx";
import { NVEWSLegend } from "../views/NVEWSLegend.jsx";
import {MapClickLogger} from "@/utils/map-utils/functions/mapHelpers.js";
import {ZoomWatcher} from "@/utils/map-utils/functions/mapHelpers.js";
import {FlyTo} from "@/utils/map-utils/functions/mapHelpers.js";
import {createIcon} from "@/utils/map-utils/functions/markerHelpers.js";
import {handleMarkerClick} from "@/utils/map-utils/functions/markerHelpers.js";
import {computePolyPositions} from "@/utils/map-utils/functions/markerHelpers.js";
import {shiftPos} from "@/utils/map-utils/functions/markerHelpers.js";
import volcanoDefault from "@/utils/map-utils/icons/green-triangle.svg";
import redVolcano from "@/utils/map-utils/icons/red-triangle.svg";
import yellowVolcano from "@/utils/map-utils/icons/yellow-triangle.svg";
import seismicDefault from "@/utils/map-utils/icons/triangle-black.svg";
import cameraDefault from "@/utils/map-utils/icons/camera.svg";
import gpsDefault from "@/utils/map-utils/icons/gps-solid.svg";
import soundIcon from "@/utils/map-utils/icons/sound-black.svg";
import tiltIcon from "@/utils/map-utils/icons/line-segment.svg";
import cloudIcon from "@/utils/map-utils/icons/cloud.svg";
export default function NVEWSMap ({type=[], origin=[], markers=[], mapFilters=[], zoom, selectedFilters, setFilterValue,
toggleInArray, selectedMarker, overlays = {}}) {
const [target, setTarget] = useState(null);
const [zoomLevel, setZoomLevel] = useState(zoom);
const link= type[0]
const attr = type[1]
const pos = [origin[0],origin[1]]
const enabledOverlays = selectedFilters?.overlay ?? [];
return (
<MapContainer center={pos} zoom={zoom} worldCopyJump={true} style = {{height:"100%",width:"100%"}} >
<MapClickLogger />
<ZoomWatcher onZoom={setZoomLevel}/>
<TileLayer url = {link} attribution = {attr}/>
<MapButtonControls settingsBtn =
{<MapFilter config ={mapFilters}
selected={selectedFilters}
setFilterValue={setFilterValue}
toggleInArray={toggleInArray}/>}
position="topright" />
<MapButtonControls settingsBtn = {<MapLegend legend={NVEWSLegend}/>}
position="bottomright" />
{enabledOverlays.map(id => (
<React.Fragment key={id}>
{overlays[id] ?? null}
</React.Fragment>
))}
<FlyTo position={target} zoom={9} />
<FlyTo position={selectedMarker} zoom={9} />
{markers.map(marker => {
let bounds = null;
let stationTypes = ["seismic_stations", "camera_stations", "gas_stations", "gnss_stations", "tiltmeter_stations",
"infrasound_stations"];
let stationMarkers = [];
let volcanoIcon = null;
const val = Number(String(marker.network_pct_complete).replace("%",""));
if(val < 70){
volcanoIcon = createIcon(redVolcano);
}
else if(val > 100){
volcanoIcon = createIcon(yellowVolcano);
}
else{
volcanoIcon = createIcon(volcanoDefault);
}
if(zoomLevel >= 7 && marker.include_reason === "Within Radius"){
bounds = <Circle center={[marker.latitude, marker.longitude]} radius={marker.nvews_radius_km * 1000}
pathOptions={{fill: false, color:'black'}}> </Circle>;
}
else if (zoomLevel >= 7 && marker.include_reason === "Within Polygon"){
bounds = <Polyline positions={computePolyPositions(marker)}
pathOptions={{color: 'black'}}> </Polyline>;
}
// const rawSelected = selectedFilters?.stations ?? [];
//
// const enabledStations =
// rawSelected.length === 0 || rawSelected.length === stationTypes.length
// ? stationTypes
// : rawSelected;
const enabledStations = selectedFilters?.stations ?? [];
if(zoomLevel >= 9){
for(const station of stationTypes){
if(marker[station].length > 0 && enabledStations.includes(station)){
for(let i =0 ; i< marker[station].length; i++){
stationMarkers.push(marker[station][i]);
}
}
}
}
return(
<React.Fragment key={marker.volcano_name}>
<Marker icon={volcanoIcon} position={[marker.latitude, marker.longitude]} eventHandlers={{
dblclick: () => handleMarkerClick(marker,setTarget)
}}>
<Popup>
<Box maxH="200px" maxW="200px" overflow="auto" borderRadius="md" bg="white">
<Text fontSize="xs" fontWeight="bold">{marker.volcano_name}</Text>
<Text fontSize="xs">Network Completion: {marker.network_pct_complete}</Text>
<Text fontSize="xs">Seismic Count: {marker.seismic_station_total} Target: {marker.seismic_target}</Text>
<Text fontSize="xs">GNSS Count: {marker.gnss_station_total} Target: {marker.gnss_target}</Text>
<Text fontSize="xs">Camera Count: {marker.camera_station_total} Target: {marker.camera_target}</Text>
<Text fontSize="xs">InfraSound Count: {marker.infrasound_station_total} Target: {marker.infrasound_target}</Text>
<Text fontSize="xs">Tilt Meter Count: {marker.tiltmeter_station_total} Target: {marker.tiltmeter_target}</Text>
<Text fontSize="xs">Real-Time Gas Count: {marker.gas_station_total} Target: {marker.gas_target}</Text>
</Box>
</Popup>
</Marker>
{bounds}
{stationMarkers.map(subMarker => {
let iconType = null;
if(subMarker.category === "Seismometer"){
iconType = createIcon(seismicDefault);
}
else if(subMarker.category === "Camera"){
iconType = createIcon(cameraDefault);
}
else if(subMarker.category === "GPS"){
iconType = createIcon(gpsDefault);
}
else if(subMarker.category === "Infrasound"){
iconType = createIcon(soundIcon);
}
else if(subMarker.category === "Tiltmeter"){
iconType = createIcon(tiltIcon);
}
else if(subMarker.category === "Gas"){
iconType = createIcon(cloudIcon);
}
// TODO: Add other icons as needed
return (
<Marker key={`${subMarker.station_id}-${subMarker.category}`}
position={[subMarker.latitude, subMarker.longitude]}
icon={iconType}>
<Popup>
<Box maxH="200px" maxW="200px" overflow="auto" borderRadius="md" bg="white">
<Text fontSize="xs" fontWeight="bold">{subMarker.station}</Text>
<Text fontSize="xs">Type: {subMarker.category}</Text>
<Text fontSize="xs">Sensor Total: {subMarker.sensor_total}</Text>
</Box>
</Popup>
</Marker>
)
})}
</React.Fragment>
)
})}
</MapContainer>
);
}
+40 -105
View File
@@ -8,127 +8,62 @@ import DialogPopup from './Canvas/CanvasComponents/DialogPopup.jsx';
export default function Dashboard() {
const recipe = useRecipe({ key: "dashboard" });
const styles = recipe();
const dialog = useDialog();
const [darkMode, setDarkMode] = useState(false);
const [isEditable, setIsEditable] = useState(false); // editable=true means static=false (vice versa)
const [isDelete, setIsDelete] = useState(false);
const [originalLayout, setOriginalLayout] = useState([]);
const [originalWidgets, setOriginalWidgets] = useState([]);
const [view, setView] = useState("mydashboard");
const emptyDash = () => ({
layout: [],
widgetArray: [],
originalLayout: [],
originalWidgets: [],
isEditable: false,
isDelete: false,
})
// Add views as needed to initialize widget canvas views
const CANVAS_VIEWS = new Set([ "mydashboard", "gas" ]);
// Widget dashboard state
const [dashboards, setDashboards] = useState(() => ({
mydashboard: emptyDash(),
gas: emptyDash(),
}));
const isCanvasView = CANVAS_VIEWS.has(view);
const dash = dashboards[view] ?? emptyDash();
const updateDash = (key, updater) => {
setDashboards(prev => ({
...prev,
[key]: updater(prev[key] ?? emptyDash()),
}));
};
// Widget setter functions
const setLayout = (fnOrValue) => {
if (!isCanvasView) return;
updateDash(view, s => ({
...s,
layout: typeof fnOrValue === "function" ? fnOrValue( s.layout ) : fnOrValue,
}));
};
const setWidgetArray = (fnOrValue) => {
if (!isCanvasView) return;
updateDash(view, s => ({
...s,
widgetArray: typeof fnOrValue === "function" ? fnOrValue(s.widgetArray) : fnOrValue,
}));
};
const setIsEditable = (value) => {
if (!isCanvasView) return;
updateDash(view, s => ({ ...s, isEditable: value }));
};
const setIsDelete = (value) => {
if (!isCanvasView) return;
updateDash(view, s => ({ ...s, isDelete: value }));
};
const [layout, setLayout] = useState([]);
const [widgetArray, setWidgetArray] = useState([]);
const dialog = useDialog();
const beginEditIfNeeded = () => {
if (!isCanvasView) return;
updateDash(view, s => {
if(s.isEditable) return s; // already editing
return {
...s,
originalLayout: s.layout,
originalWidgets: s.widgetArray,
};
});
if ( !isEditable ) {
setOriginalLayout(layout);
setOriginalWidgets(widgetArray);
}
};
const onCancel = () => {
if (!isCanvasView) return;
updateDash(view, s => ({
...s,
layout: (s.originalLayout?.length ? s.originalLayout : s.layout).map(it => ({
...it,
static: true,
})),
widgetArray: s.originalWidgets ?? s.widgetArray,
isEditable: false,
isDelete: false,
originalLayout: [],
originalWidgets: [],
}));
};
const onLayoutChange = (newLayout) => {
setLayout(newLayout);
};
console.log("canceling changes...");
console.log(originalWidgets);
if (originalLayout.length) {
setLayout(originalLayout.map(item => ({
...item,
static: true
})));
};
setWidgetArray(originalWidgets);
setIsEditable(false);
setIsDelete(false);
setOriginalLayout([]);
setOriginalWidgets([]);
}
// Prevent navigation while in Edit mode without saving or cancelling
const handleViewChange = (nextView) => {
// prevent nav only when current view is a widget canvas AND you're editing
if (CANVAS_VIEWS.has(view) && dash.isEditable) {
console.log("1. handling view change")
if (view === "mydashboard" && isEditable) {
console.log("2. currently editing");
dialog.setOpen(true);
return;
// const confirmed = window.confirm(
// "You have unsaved changes. Discard them and continue?"
// );
// if (!confirmed) return;
// onCancel();
}
setView(nextView);
};
const onLayoutChange = (newLayout) => setLayout(newLayout);
// Prop Forwarding
const canvasProps = {
view,
onChangeView: handleViewChange,
// active canvas props
layout: dash.layout,
widgetArray: dash.widgetArray,
isEditable: dash.isEditable,
isDelete: dash.isDelete,
// handler functions
setLayout,
setWidgetArray,
setIsEditable,
setIsDelete,
beginEditIfNeeded,
onCancel,
onLayoutChange,
const canvasProps = { view, layout, setLayout, setOriginalLayout, onLayoutChange, onChangeView: handleViewChange,
onCancel, isEditable, setIsEditable, isDelete, setIsDelete, widgetArray, setWidgetArray, originalWidgets, setOriginalWidgets, beginEditIfNeeded
};
const sidebarProps = { view, onChangeView: handleViewChange, darkMode, setDarkMode };
const headerProps = { onChangeView: handleViewChange };
+4 -19
View File
@@ -1,35 +1,20 @@
import { chakra, useRecipe, Box, Button, Stack, Text, Flex, Image } from "@chakra-ui/react"
import { useColorMode } from "@/components/ui/color-mode";
import { chakra, useRecipe, Box, Button, Stack, Text, Flex } from "@chakra-ui/react"
import UserAvatarMenu from "./UserAvatarMenu.jsx";
//TODO: Dynamically populate per each user
const user = {
name: "Dan Hansen",
email: "dshansen@contractor.usgs.gov",
name: "venessa kuchenik",
email: "vkuchenik@contractor.usgs.gov",
// avatar: "src\\assets\\user_profile.svg"
}
export default function Header({ onChangeView }) {
const recipe = useRecipe({ key: "header" });
const styles = recipe();
const { colorMode } = useColorMode();
const logoSource =
colorMode === "dark"
? "/images/NVIS_Logo_Black.png"
: "/images/NVIS_Logo_transparent.png"
return (
<chakra.div css={styles}>
<Button
onClick={() => onChangeView("mydashboard")}
// color="color"
variant="plain"
// textStyle="5xl"
// fontWeight="bold"
>
<Image src={ logoSource } alt="NVIS Logo" h="60px" />
</Button>
<Button onClick={() => onChangeView("mydashboard")} color="color" variant="plain" textStyle="5xl" fontWeight="bold">VDAP</Button>
<Flex align="center" gap={3} pr={6}>
<Stack gap="0" textAlign="center" cursor="default">
<Text color="color" fontWeight="bold" textStyle="lg">{user.name}</Text>
+1 -1
View File
@@ -43,7 +43,7 @@ export default function Sidebar({ view, onChangeView, darkMode, setDarkMode }) {
// Track currrent view from both Sidebar and Canvas when 'view' changes
useEffect(() => {
//console.log("Current view = ", view);
console.log("Current view = ", view);
const newIndex = buttons.findIndex((btn) => {
if (btn.view === "mydashboard") {
return DASHBOARD_VIEWS.includes(view);
-7
View File
@@ -2,7 +2,6 @@
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react'
import { ThemeProvider, useTheme } from 'next-themes'
import { useEffect } from "react"
import * as React from 'react'
import { LuMoon, LuSun } from 'react-icons/lu'
@@ -19,12 +18,6 @@ export function useColorMode() {
const toggleColorMode = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
}
// Debug log
useEffect(() => {
//console.log(`Color mode changed to: ${colorMode}`);
}, [colorMode]);
return {
colorMode: colorMode,
setColorMode: setTheme,
+5 -16
View File
@@ -1,21 +1,12 @@
// Main views called from Home tab -> Canvas navigation header
// 'isCanvas' is used to detirmine which views contain widgets and their state
// Add seismic, remote, and deformation once ready to add additional widgets
export const DASHBOARD_TABS = [
{ value: "mydashboard", label: "My Dashboard", isCanvas: true },
{ value: "gas", label: "Gas", isCanvas: true },
{ value: "seismic", label: "Seismic", isCanvas: false },
{ value: "remote", label: "Remote Sensing", isCanvas: false },
{ value: "deformation", label: "Deformation", isCanvas: false },
{ value: "daily", label: "Daily Activity", isCanvas: false },
{ value: "mydashboard", label: "My Dashboard" },
{ value: "gas", label: "Gas" },
{ value: "seismic", label: "Seismic" },
{ value: "remote", label: "Remote Sensing" },
{ value: "daily", label: "Daily Activity" },
]
// Used to check if widget state needs to be initialized per view
// Set has O(1) lookup, trying it becuase it seems fast
export const DASHBOARD_CANVAS_VIEWS = new Set(
DASHBOARD_TABS.filter(t => t.isCanvas).map(t => t.value)
);
// Volcano views called from Volcano tab -> Canvas navigation header
export const VOLCANO_TABS = [
{ value: "volcanohome", label: "Volcano Home" },
@@ -28,7 +19,6 @@ export const VOLCANO_TABS = [
// Admin views called from Admin tab -> Canvas navigation header
export const ADMIN_TABS = [
{ value: "medataentry", label: "M&E Data Entry" },
{ value: "nvews", label: "NVEWS" },
{ value: "projectcollector", label: "Project Collector" },
{ value: "nasapplication", label: "NAS Application" },
{ value: "ivans", label: "IVANS" },
@@ -51,7 +41,6 @@ export const VIEW_LABELS = {
global: "Global Map",
regional: "Regional Map",
volcano: "Volcano",
deformation: "Deformation",
// Volcano navigation tabs
volcanohome: "Volcano Home",
@@ -1,97 +0,0 @@
export const WIDGET_INSPECTOR_REGISTRY = {
"gas_measurements": {
title: "Gas Measurements Settings",
defaultConfig: {
volcano_name: null,
site_name: null,
compound_name: [],
start_date: null,
end_date: null,
include_measurements: true,
include_personnel: false, // optional
},
// Option sources for this widget's fields (from /options endpoint)
optionSources: {
options: {
url: "/options", // relative to API_BASE
cacheKey: "options", // used by generic loader
},
sites: {
url: ({ draft }) => {
const v = draft?.volcano_name;
return v
? `/options/sites?volcano_name=${encodeURIComponent(v)}`
: "/options/sites";
},
cacheKey: ({ draft }) => `sites:${draft?.volcano_name ?? "all"}`,
dependsOn: ["volcano_name"]
},
},
// How to render + bind each config key
fields: [
{
key: "volcano_name",
label: "Volcano",
type: "select",
placeholder: "(all)",
optionsFrom: { source: "options", path: "volcano_name" },
clearOnChange: ["site_name"], // when volcano changes, clear site
},
{
key: "site_name",
label: "Site",
type: "select",
placeholder: "(all)",
optionsFrom: { source: "sites", path: "site_name" },
},
// Single Select Option
// {
// key: "compound",
// label: "Compound",
// type: "select",
// placeholder: "(all)",
// optionsFrom: { source: "options", path: "compound_name" },
// },
{
// Multiple Select Option
key: "compound_name",
label: "Compounds",
type: "checkbox",
placeholder: "(all)",
optionsFrom: { source: "options", path: "compound_name" },
},
{
key: "date_interval",
label: "Date Interval",
type: "dateRange",
startKey: "start_date",
endKey: "end_date",
minFrom: { source: "options", path: "date_min" },
maxFrom: { source: "options", path: "date_max" },
presets: [
{ label: "Use full range", startFrom: "date_min", endFrom: "date_max" },
{ label: "Clear", start: null, end: null, variant: "ghost" },
],
validate: (draft) => {
const s = draft.start_date;
const e = draft.end_date;
if (s && e && s > e) return "Start date must be on or before end date.";
return null;
},
},
],
// Optional: control exactly what gets submitted (prevents "junk" keys)
submitKeys: [
"volcano_name",
"site_name",
"compound_name",
"start_date",
"end_date",
"include_measurements",
"include_personnel",
],
},
};
-106
View File
@@ -1,106 +0,0 @@
// This file is intended to track default widget states as a baseline for any created widgets
import AlertLevel from "src/components/Canvas/CanvasComponents/AlertLevelWidget.jsx"
import GasWidget from "src/components/Canvas/CanvasComponents/GasWidget";
// Example widgets, returns actual baseline widget
export const WIDGET_REGISTRY = {
// ***************** My Dashboard Widgets *****************
alert_level: {
key: "alert_level",
label: "Alert Level",
kind: "real",
defaults: { w: 4, h: 7, minW: 4, minH: 7, maxW: 8, maxH: 14 },
category: "generic",
},
ivans: {
key: "ivans",
label: "IVANS",
kind: "placeholder",
placeholderTitle: "IVANS",
defaults: {w: 4, h: 7, minW: 4, minH: 5, maxW: 8, maxH: 12 },
category: "generic",
},
plume_heights: {
key: "plume_heights",
label: "Plume Heights",
kind: "placeholder",
placeholderTitle: "Plume Heights",
defaults: {w: 4, h: 7, minW: 4, minH: 5, maxW: 8, maxH: 12 },
category: "generic",
},
alert_level_changes: {
key: "alert_level_changes",
label: "Alert Level Changes",
kind: "placeholder",
placeholderTitle: "Alert Level Changes",
defaults: {w: 4, h: 7, minW: 4, minH: 5, maxW: 8, maxH: 12 },
category: "generic",
},
// ***************** Gas Widgets *****************
gas_measurements: {
key: "gas_measurements",
label: "Gas Measurements",
kind: "real",
defaults: {w: 4, h: 6, minW: 4, minH: 5, maxW: 8, maxH: 12 },
category: "gas",
// ~ defaultConfig is being moved to widgetInspectorRegistry
// defaultConfig: {
// // for later filters
// rangeDays: 30,
// compound: null,
// volcano: null,
// site: null,
// },
},
gas_so2: {
key: "gas_so2",
label: "SO₂",
kind: "placeholder",
placeholderTitle: "SO2",
defaults: {w: 4, h: 7, minW: 4, minH: 5, maxW: 8, maxH: 12 },
category: "gas",
},
// Add Additional Widgets to the Registry as they are generated
// Gas Widgets
// Seismic Widgets
// Remote Sensing Widgets
// Deformation Widgets
};
// Used to define components without adding .jsx to a .js registry file
export const WIDGET_COMPONENTS = {
alert_level: AlertLevel,
gas_measurements: GasWidget, // clicking CO2 renders the temp gas widget
};
// Which widgets show up in "Add Widget" for each view
// Make sure these match the keys inside the WIDGET_REGISTRY
export const VIEW_WIDGET_KEYS = {
mydashboard: [
"alert_level",
"gas_measurements",
"ivans",
"plume_heights",
"alert_level_changes"
],
gas: [
"gas_measurements",
"gas_so2",
],
seismic: ["alert_level"],
};
export const getWidgetsForView = (view) => {
const keys = VIEW_WIDGET_KEYS[view] ?? [];
return keys
.map((k) => WIDGET_REGISTRY[k])
.filter(Boolean);
};
+2 -5
View File
@@ -72,7 +72,7 @@ const widgetRecipe = defineSlotRecipe({
borderColor: "{base300}",
textAlign: "center",
height: "100%",
//position: "fixed"
position: "fixed"
},
header: {
flexShrink: "0",
@@ -138,21 +138,18 @@ const config = defineConfig({
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
},
'.leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-attribution, .leaflet-container': {
'.leaflet-control-zoom-in .leaflet-control-zoom-out .leaflet-control-attribution .leaflet-container': {
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
},
'.leaflet-control-attribution': {
maxWidth: "100%",
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
},
'.leaflet-container': {
fontFamily: "georgia, system-ui, sans-serif !important",
width: "100%",
height: "100%",
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
@@ -1,57 +0,0 @@
import {useMap, useMapEvents} from "react-leaflet";
import {useEffect} from "react";
/**
* Helper component for console logging where on a map the user clicked for debugging purposes.
*
* This component renders nothing and is intended to be mounted inside a <MapContainer>
*/
export function MapClickLogger() {
const map = useMap();
useMapEvents({
click(e) {
console.log("lat:", e.latlng.lat, "lng:", e.latlng.lng);
},
});
return null;
}
/**
* Helper component that detects when a zoom change as completed and sets the callback to the updated zoom level
*
* This component renders nothing and is intended to be mounted inside a <MapContainer>
*
* @param onZoom Callback set to the current zoom level on zoomend
*/
export function ZoomWatcher({onZoom}){
const map = useMap();
useMapEvents({
zoomend(e){
onZoom(map.getZoom());
}
})
return null;
}
/**
* Helper component that take triggers a flyTo animation when a position is present
*
* This component returns nothing and is intended to be mounted inside a <MapConatiner>
*
* @param position Array of two integers representing the desired coordinates to flyTo
* @param zoom Integer representing the desired zoom level to display
*/
export function FlyTo({ position, zoom }) {
const map = useMap();
useEffect(() => {
if (!position) return;
map.flyTo(position, zoom, { animate: true });
}, [position, zoom, map]);
return null;
}
@@ -1,63 +0,0 @@
import L from "leaflet";
/**
* Takes an image URL and optional size, anchor, popup params and returns a Leaflet Icon.
*
* @param path Image URL
* @param size Array of 2 integers representing the size of the icon
* @param anchor Array of 2 integers representing the point of the icon which corresponds to the icon location
* @param popup Array of 2 integers representing the position of the popup relative to the icon acnhor
* @returns A Leaflet Icon
*/
export function createIcon(path,
{size=[24,24], anchor=[12,24], popup=[0,-20]} = {}){
return L.icon({
iconUrl: path,
iconSize: size,
iconAnchor: anchor,
popupAnchor: popup
});
}
/**
* Updates a state setter with the latitude and longitude of a marker.
*
* @param marker Marker Object with lat and lon fields
* @param setter React Hook to be set with Marker position values
*/
export function handleMarkerClick(marker,setter){
setter([marker.latitude,marker.longitude]);
}
/**
* Returns an array of positions representing points from which to draw Leaflet polygons or polylines.
*
* @param marker Object with polygon field
* @returns Array of positions that can be passed to Leaflet polygons/polylines
*/
export function computePolyPositions(marker) {
const positions = [];
for (const pos of marker.polygon) {
positions.push([pos[1], pos[0]]);
}
//console.log(positions);
return positions;
}
/**
* Returns a latitude integer shifted by 360 degrees.
*
* @param lon Integer representing a latitude value
* @returns An integer representing the latitude value shifted by 360 degrees
*/
export function shiftPos(lon){
if( lon > 0){
return lon - 360;
}
return lon;
}
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<polygon points="50,10 90,90 10,90" fill="black"></polygon>
</svg>

Before

Width:  |  Height:  |  Size: 157 B

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H0V14H16V3H13L11 1H5L3 3ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 432 B

@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.178 18.555H18.28a4.7 4.7 0 1 0 0-9.4 5.319 5.319 0 0 0-.783.07A6.267 6.267 0 0 0 5.87 11.042c-.082.41-.124.828-.125 1.246v.446a3.133 3.133 0 1 0-1.567 5.82Z" fill="#000000" fill-opacity=".16" stroke="#000000" stroke-width="1.5" stroke-miterlimit="10" stroke-linejoin="round"/></svg>

Before

Width:  |  Height:  |  Size: 554 B

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 54.9 54.9" xml:space="preserve">
<g>
<path d="M44.885,12.23c-0.842-2.599-1.994-4.456-4.455-4.456H20.618V3.523c0-1.946-1.684-3.523-3.76-3.523
c-2.077,0-3.761,1.577-3.761,3.523c0,1.254,0,3.272,0,4.555c-1.513,0.641-2.407,2.263-2.972,4.152c0,0-1.633,11.68-1.633,19.108
c0,7.427,1.633,19.105,1.633,19.105c0.761,2.492,1.996,4.457,4.456,4.457H40.43c2.461,0,3.676-1.938,4.455-4.457
c0,0,1.594-12.272,1.521-19.105C46.364,23.687,44.885,12.23,44.885,12.23z M40.614,15.055v19.45c0,0.974-0.789,1.761-1.761,1.761
H15.97c-0.973,0-1.761-0.787-1.761-1.761v-19.45c0-0.973,0.788-1.76,1.761-1.76h22.883C39.825,13.296,40.614,14.083,40.614,15.055z
M21.453,46.357h-6.398c-0.922,0-1.669-0.748-1.669-1.67s0.747-1.668,1.669-1.668h6.398c0.921,0,1.669,0.746,1.669,1.668
C23.121,45.609,22.373,46.357,21.453,46.357z M27.45,47.654c-1.639,0-2.967-1.326-2.967-2.967c0-1.64,1.328-2.967,2.967-2.967
c1.64,0,2.967,1.327,2.967,2.967C30.416,46.328,29.089,47.654,27.45,47.654z M40.227,46.357h-6.397
c-0.921,0-1.669-0.748-1.669-1.67s0.748-1.668,1.669-1.668h6.397c0.922,0,1.67,0.746,1.67,1.668S41.148,46.357,40.227,46.357z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<polygon points="50,10 90,90 10,90" fill="green"></polygon>
</svg>

Before

Width:  |  Height:  |  Size: 157 B

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 256 256" id="Flat" xmlns="http://www.w3.org/2000/svg">
<path d="M214.62793,86.62695a32.0716,32.0716,0,0,1-38.88245,4.94141L91.56836,175.74561a32.00172,32.00172,0,1,1-50.19629-6.37256l.00049-.001a32.05731,32.05731,0,0,1,38.88208-4.94043l84.177-84.17725a32.00172,32.00172,0,1,1,50.19629,6.37256Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 486 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<polygon points="50,10 90,90 10,90" fill="red"></polygon>
</svg>

Before

Width:  |  Height:  |  Size: 155 B

@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="sound-max" class="icon glyph"><path d="M18.36,19.36a1,1,0,0,1-.7-.29,1,1,0,0,1,0-1.41,8,8,0,0,0,0-11.32,1,1,0,0,1,1.41-1.41,10,10,0,0,1,0,14.14A1,1,0,0,1,18.36,19.36Z" style="fill:#231f20"></path><path d="M15.54,16.54a1,1,0,0,1-.71-.3,1,1,0,0,1,0-1.41,4,4,0,0,0,0-5.66,1,1,0,0,1,1.41-1.41,6,6,0,0,1,0,8.48A1,1,0,0,1,15.54,16.54Z" style="fill:#231f20"></path><path d="M11.38,4.08a1,1,0,0,0-1.09.21L6.59,8H4a2,2,0,0,0-2,2v4a2,2,0,0,0,2,2H6.59l3.7,3.71A1,1,0,0,0,11,20a.84.84,0,0,0,.38-.08A1,1,0,0,0,12,19V5A1,1,0,0,0,11.38,4.08Z" style="fill:#231f20"></path></svg>

Before

Width:  |  Height:  |  Size: 774 B

@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M21,21H3L12,3Z"/></svg>

Before

Width:  |  Height:  |  Size: 255 B

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M78 350.117C102.927 328.203 133.852 298.075 150.466 269.501C153.06 265.041 170.499 236.901 173.632 234.389C176.602 232.004 187.902 237.356 191.872 237.356C200.733 237.356 211.211 240.36 219.97 237.848C221.387 237.442 224.288 235.164 225.885 236.364C226.24 236.631 237.511 257.664 238.704 259.611C247.144 273.374 258.88 286.776 270.253 298.186C280.556 308.522 300.364 328.666 311.66 336.765C315.602 339.588 319.544 341.211 323 344.675" stroke="#000000" stroke-opacity="0.9" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M131.38 304.95C135.533 298.603 159.228 276.623 168.321 282.748C175.754 287.761 173.405 302.504 184.793 303.942C194.738 305.196 195.126 288.827 205.758 287.293C210.325 286.632 211.64 294.873 216.24 294.355C221.126 293.807 223.198 288.572 228.721 289.814C239.774 292.298 247.998 299.732 258.67 302.429" stroke="#000000" stroke-opacity="0.9" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path opacity="0.350774" d="M157.885 213.246C155.999 204.036 155.569 194.785 157.885 185.736C158.412 183.693 161.735 180.903 159.938 179.735C156.568 177.542 144.359 181.571 139.426 160.73C137.152 151.11 134.573 132.317 140.454 123.717C142.562 120.629 149.739 120.136 150.708 118.714C152.68 115.829 148.048 107.361 148.655 103.208C150.274 92.1621 160.18 75.0189 169.168 68.1965C182.694 57.938 190.309 70.1404 191.218 69.6969C193.411 68.6317 198.115 61.0176 201.986 58.6965C219.358 48.268 246.181 42.9004 260.956 60.1969C269.604 70.3145 259.711 84.664 261.984 90.2057C262.163 90.6409 276.819 93.3309 278.392 94.2055C295.695 103.851 311.006 119.999 309.162 140.723C307.896 154.882 289.551 175.018 275.829 178.736C274.287 179.154 269.468 179.399 267.623 179.233C266.383 179.125 264.428 177.082 264.033 178.234C263.62 179.444 268.163 184.476 268.648 185.736C274.988 202.216 265.89 208.666 251.216 212.247" stroke="#000000" stroke-opacity="0.9" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path opacity="0.350774" d="M186.681 166.71C177.082 161.223 168.181 144.511 174.642 133.555C175.573 131.978 178.35 132.139 179.157 130.495C181.206 126.331 178.944 118.554 180.662 113.664C185.347 100.328 200.039 95.2478 210.765 103.972" stroke="#000000" stroke-opacity="0.9" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path opacity="0.350774" d="M225.187 135.247C241.769 118.44 258.35 135.578 243.013 153.73C240.342 156.889 235.386 155.858 233.163 158.226C232.518 158.913 234.162 159.857 234.57 160.722C236.113 164.017 237.147 167.983 236.444 171.713C233.979 184.852 225.401 192.581 214.872 198.19" stroke="#000000" stroke-opacity="0.9" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" version="1.1" id="volcano" xmlns="http://www.w3.org/2000/svg">
<path id="path6447" d="M8.4844,1.0002&#xA;&#x9;c-0.1464,0.005-0.2835,0.0731-0.375,0.1875L6.4492,3.2619L4.8438,1.7385C4.4079,1.3374,3.7599,1.893,4.0899,2.385l1.666,2.4004&#xA;&#x9;C5.9472,5.061,6.3503,5.0737,6.5586,4.8108C6.7249,4.6009,7,4.133,7.5,4.133s0.7929,0.4907,0.9414,0.6777&#xA;&#x9;c0.175,0.2204,0.4973,0.2531,0.7129,0.0723l1.668-1.4004c0.4408-0.3741,0.0006-1.0735-0.5273-0.8379L9,3.2268V1.5002&#xA;&#x9;C9.0002,1.2179,8.7666,0.9915,8.4844,1.0002L8.4844,1.0002z M5,6.0002L2.0762,11.924C1.9993,12.0009,2,12.155,2,12.3088&#xA;&#x9;c0,0.5385,0.3837,0.6914,0.6914,0.6914h9.6172c0.3846,0,0.6914-0.153,0.6914-0.6914c0-0.1538,0.0008-0.2309-0.0762-0.3848L10,6.0002&#xA;&#x9;c-0.5,0-1,0.5-1,1v0.5c0,0.277-0.223,0.5-0.5,0.5S8,7.7772,8,7.5002v-0.5c0-0.2761-0.2238-0.5-0.5-0.5S7,6.7241,7,7.0002v2&#xA;&#x9;c0,0.277-0.223,0.5-0.5,0.5S6,9.2772,6,9.0002v-2C6,6.5002,5.5,6.0002,5,6.0002z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<polygon points="50,10 90,90 10,90" fill="yellow"></polygon>
</svg>

Before

Width:  |  Height:  |  Size: 158 B

@@ -1,44 +0,0 @@
export const mapFilterConfig = [
{
id: "observatory",
label: "Observatory",
type: "checkbox",
options: [
{ label: "CVO", value: "CVO" },
{ label: "HVO", value: "HVO" },
{ label: "AVO", value: "AVO" },
{ label: "YVO", value: "YVO" },
],
target: { kind: "data", field: "volcano_group", op: "in" },
defaultValue: [],
},
{
id:"stations",
label: "Stations",
type: "checkbox",
options: [
{ label: "Seismic", value: "seismic_stations"},
{ label: "GNSS", value: "gnss_stations"},
{ label: "Cameras", value: "camera_stations"},
{ label: "Infrasound", value: "infrasound_stations"},
{ label: "Tilt", value: "tiltmeter_stations"},
{ label: "Real-Time Gas", value: "gas_stations"},
],
target: { kind: "stationType"},
defaultValue: [],
},
// {
// id: "overlay",
// label: "Overlay",
// type: "checkbox",
// options: [
// { label: "Overlay 1", value: "overlay1" },
// { label: "Overlay 2", value: "overlay2" },
// ],
// target: { kind: "mapOverlay" },
// defaultValue: [],
// },
];
-7
View File
@@ -1,7 +0,0 @@
export function pctStyles(val, high, mid) {
if (val == null || Number.isNaN(val)) return {};
if (val < mid) return { bg: "red.100", color: "red.800", fontWeight: "semibold" };
if (val < high) return { bg: "yellow.100", color: "yellow.800" };
if (val > high) return { bg: "blue.100", color: "blue.800" };
return { bg: "green.100", color: "green.800" };
}