Fix add widget behavior, widget types and view settings button menu item...
@@ -18,12 +18,14 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"plotly.js": "^3.3.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-grid-layout": "^1.5.2",
|
"react-grid-layout": "^1.5.2",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-plotly.js": "^2.6.0",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -1,11 +1,14 @@
|
|||||||
import Widget from "./Widget";
|
import Widget from "./Widget";
|
||||||
import { Stack, Box, Field, Input, Button, Text, Flex } from "@chakra-ui/react";
|
import { Stack, Box, Field, Input, Button, ButtonGroup, Text, Flex } from "@chakra-ui/react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LuAsterisk } from "react-icons/lu";
|
import { LuAsterisk } from "react-icons/lu";
|
||||||
|
import { CancelButton } from "./CancelButton.jsx";
|
||||||
|
|
||||||
|
|
||||||
export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
|
export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
|
||||||
const { register, handleSubmit, reset, formState: {errors} } = useForm();
|
const { register, handleSubmit, reset, formState: {errors} } = useForm();
|
||||||
|
|
||||||
const onSubmit = (data) => {
|
const onSubmit = (data) => {
|
||||||
console.log("data: ", data);
|
console.log("data: ", data);
|
||||||
if (data.volcanoname === '') {
|
if (data.volcanoname === '') {
|
||||||
@@ -14,6 +17,10 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
|
|||||||
setIsSubmit(true);
|
setIsSubmit(true);
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
const onCancel = () => {
|
||||||
|
reset();
|
||||||
|
setIsSubmit(false);
|
||||||
|
}
|
||||||
const [isSubmit, setIsSubmit] = useState(false);
|
const [isSubmit, setIsSubmit] = useState(false);
|
||||||
|
|
||||||
// Prop Forwarding
|
// Prop Forwarding
|
||||||
@@ -32,7 +39,7 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
|
|||||||
pr={3}
|
pr={3}
|
||||||
display="flex"
|
display="flex"
|
||||||
flexDir="column"
|
flexDir="column"
|
||||||
gap="10"
|
gap={12}
|
||||||
>
|
>
|
||||||
<Field.Root orientation="horizontal">
|
<Field.Root orientation="horizontal">
|
||||||
<Stack direction="column" gap="3" width="100%">
|
<Stack direction="column" gap="3" width="100%">
|
||||||
@@ -56,7 +63,12 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Field.Root>
|
</Field.Root>
|
||||||
<Button type="submit" size="sm">Submit</Button>
|
<Flex justify ="flex-end" gap = {2}>
|
||||||
|
<ButtonGroup size="sm" variant="solid">
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
<CancelButton onCancel ={onCancel}/>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
):(
|
):(
|
||||||
// DISPLAYING ALERT LEVEL DATA
|
// DISPLAYING ALERT LEVEL DATA
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { Button } from "@chakra-ui/react";
|
|||||||
|
|
||||||
export function CancelButton ({onCancel}) {
|
export function CancelButton ({onCancel}) {
|
||||||
return (
|
return (
|
||||||
<Button onClick={onCancel} borderRadius="md">Cancel</Button>
|
<Button onClick={onCancel} type={"button"}>Cancel</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Dialog, Portal, CloseButton, Button } from "@chakra-ui/react";
|
import { Dialog, Portal, CloseButton, Button } from "@chakra-ui/react";
|
||||||
|
|
||||||
export default function DialogPopup({dialog}) {
|
export default function DialogPopup({dialog}) {
|
||||||
console.log("3. dialog popup")
|
//console.log("3. dialog popup")
|
||||||
return(
|
return(
|
||||||
<Dialog.RootProvider value={dialog}>
|
<Dialog.RootProvider value={dialog}>
|
||||||
<Portal>
|
<Portal>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, Menu, Portal } from '@chakra-ui/react'
|
import { Box, Menu, Portal } from '@chakra-ui/react'
|
||||||
import WidgetMenu from './WidgetMenu';
|
import WidgetMenu from './WidgetMenu';
|
||||||
|
|
||||||
export default function FirstWidgetButton({ appendWidget }) {
|
export default function FirstWidgetButton({ view, appendWidget }) {
|
||||||
const triggerItem = (<>
|
const triggerItem = (<>
|
||||||
<Menu.Trigger asChild>
|
<Menu.Trigger asChild>
|
||||||
<Box
|
<Box
|
||||||
@@ -16,10 +16,11 @@ export default function FirstWidgetButton({ appendWidget }) {
|
|||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
border="2px dashed"
|
border="2px dashed"
|
||||||
borderColor="gray.300"
|
borderColor="gray.300"
|
||||||
bg="gray.50"
|
bg={{base:"white",_dark:"gray"}}
|
||||||
|
color={{base:"black",_dark:"white"}}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
_hover = {{
|
_hover = {{
|
||||||
bg: "gray.100",
|
bg: {base:"gray.100", _dark:"gray.600"},
|
||||||
borderColor: "gray.400",
|
borderColor: "gray.400",
|
||||||
boxShadow: "lg",
|
boxShadow: "lg",
|
||||||
}}
|
}}
|
||||||
@@ -29,7 +30,7 @@ export default function FirstWidgetButton({ appendWidget }) {
|
|||||||
</Menu.Trigger>
|
</Menu.Trigger>
|
||||||
</>);
|
</>);
|
||||||
const positioning = { placement: "bottom-middle" };
|
const positioning = { placement: "bottom-middle" };
|
||||||
const WidgetMenuProps = { appendWidget, triggerItem, positioning };
|
const WidgetMenuProps = { view, appendWidget, triggerItem, positioning };
|
||||||
return (
|
return (
|
||||||
<WidgetMenu {...WidgetMenuProps} />
|
<WidgetMenu {...WidgetMenuProps} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* 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 () => {
|
return () => {
|
||||||
control.remove();
|
control.remove();
|
||||||
};
|
};
|
||||||
}, [map, position, settingsBtn ]);
|
}, [map, position]);
|
||||||
|
|
||||||
return container ? createPortal(
|
return container ? createPortal(
|
||||||
settingsBtn,
|
settingsBtn,
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
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, chakra, Field, IconButton, Icon, Input, Fieldset, Flex } from "@chakra-ui/react";
|
import { Box, Checkbox, Separator , CloseButton, Text, Field, chakra, IconButton, Icon, Input, Fieldset, Flex } from "@chakra-ui/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FiSettings } from "react-icons/fi";
|
import { FiSettings } from "react-icons/fi";
|
||||||
import { IoIosArrowDown } from "react-icons/io";
|
import { IoIosArrowDown } from "react-icons/io";
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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,9 +3,16 @@ import { IoIosArrowBack } from "react-icons/io";
|
|||||||
import { FiSettings } from "react-icons/fi";
|
import { FiSettings } from "react-icons/fi";
|
||||||
import WidgetMenu from "./WidgetMenu";
|
import WidgetMenu from "./WidgetMenu";
|
||||||
|
|
||||||
export function SettingsButton({ onEditWidgets, appendWidget}) {
|
export function SettingsButton({ view, onEditWidgets, appendWidget}) {
|
||||||
const triggerItem = (<> <Menu.TriggerItem> <IoIosArrowBack /> Add Widget</Menu.TriggerItem> </>);
|
const triggerItem = (
|
||||||
const WidgetMenuProps = { appendWidget, triggerItem }
|
<>
|
||||||
|
<Menu.TriggerItem>
|
||||||
|
<IoIosArrowBack /> Add Widget
|
||||||
|
</Menu.TriggerItem>
|
||||||
|
</>);
|
||||||
|
|
||||||
|
const WidgetMenuProps = { view, appendWidget, triggerItem }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
<Menu.Trigger asChild >
|
<Menu.Trigger asChild >
|
||||||
@@ -13,11 +20,12 @@ export function SettingsButton({ onEditWidgets, appendWidget}) {
|
|||||||
<FiSettings />
|
<FiSettings />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Menu.Trigger>
|
</Menu.Trigger>
|
||||||
|
|
||||||
<Portal>
|
<Portal>
|
||||||
<Menu.Positioner>
|
<Menu.Positioner>
|
||||||
<Menu.Content>
|
<Menu.Content>
|
||||||
<WidgetMenu {...WidgetMenuProps}/>
|
<WidgetMenu {...WidgetMenuProps}/>
|
||||||
<Menu.Item value="eidt" onClick={onEditWidgets} >
|
<Menu.Item value="edit" onClick={onEditWidgets} >
|
||||||
Edit Widgets
|
Edit Widgets
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Content>
|
</Menu.Content>
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
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,23 +1,25 @@
|
|||||||
import { Menu, Portal } from "@chakra-ui/react";
|
import { Menu, Portal } from "@chakra-ui/react";
|
||||||
import AlertLevel from "./AlertLevelWidget";
|
import { getWidgetsForView } from "@/constants/widgetRegistry";
|
||||||
|
// import AlertLevel from "./AlertLevelWidget";
|
||||||
|
|
||||||
|
export default function WidgetMenu({view, appendWidget, triggerItem, positioning}) {
|
||||||
|
const entries = getWidgetsForView(view);
|
||||||
|
|
||||||
export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
|
|
||||||
return (
|
return (
|
||||||
<Menu.Root positioning={positioning}>
|
<Menu.Root positioning={positioning}>
|
||||||
{triggerItem}
|
{triggerItem}
|
||||||
<Portal>
|
<Portal>
|
||||||
<Menu.Positioner>
|
<Menu.Positioner>
|
||||||
<Menu.Content>
|
<Menu.Content>
|
||||||
<Menu.Item
|
{entries.map((w) => (
|
||||||
value="AlertLevel"
|
<Menu.Item
|
||||||
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
|
key={w.key}
|
||||||
|
value={w.key}
|
||||||
|
onClick={() => appendWidget(w.key)}
|
||||||
>
|
>
|
||||||
Alert Level
|
{w.label}
|
||||||
</Menu.Item>
|
</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.Content>
|
||||||
</Menu.Positioner>
|
</Menu.Positioner>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Flex, Tabs, HStack } from '@chakra-ui/react';
|
import { Flex, Tabs, HStack } from '@chakra-ui/react';
|
||||||
import { MEDataEntry } from './AdminTabs/MEDataEntry';
|
import { MEDataEntry } from './AdminTabs/MEDataEntry';
|
||||||
|
import { NVEWS } from './AdminTabs/NVEWS'
|
||||||
import { ProjectCollector } from './AdminTabs/ProjectCollector';
|
import { ProjectCollector } from './AdminTabs/ProjectCollector';
|
||||||
import { NASApplication } from './AdminTabs/NASApplication';
|
import { NASApplication } from './AdminTabs/NASApplication';
|
||||||
import { IVANS } from './AdminTabs/IVANS'
|
import { IVANS } from './AdminTabs/IVANS'
|
||||||
@@ -30,7 +31,7 @@ export default function Admin({ view, onChangeView, onEditWidgets, appendWidget
|
|||||||
as="header"
|
as="header"
|
||||||
position="sticky"
|
position="sticky"
|
||||||
top="0"
|
top="0"
|
||||||
zIndex="dropdown" // zIndex: 1100
|
zIndex={2000} // zIndex: 1100
|
||||||
bg="base200"
|
bg="base200"
|
||||||
pl={5}
|
pl={5}
|
||||||
pr={2}
|
pr={2}
|
||||||
@@ -42,25 +43,28 @@ export default function Admin({ view, onChangeView, onEditWidgets, appendWidget
|
|||||||
>
|
>
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
<CanvasHeaderTabs tabs={ADMIN_TABS} />
|
<CanvasHeaderTabs tabs={ADMIN_TABS} />
|
||||||
<HStack width="258px" justifyContent="flex-end">
|
{/*<HStack width="258px" justifyContent="flex-end">
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</HStack>
|
</HStack>*/}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* CANVAS */}
|
{/* CANVAS */}
|
||||||
<Tabs.Content value="medataentry">
|
<Tabs.Content value="medataentry">
|
||||||
<MEDataEntry />
|
<MEDataEntry />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="projectcollector">
|
<Tabs.Content value="nvews">
|
||||||
<ProjectCollector />
|
<NVEWS />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="nasapplication">
|
<Tabs.Content value="projectcollector">
|
||||||
<NASApplication />
|
<ProjectCollector />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="ivans">
|
<Tabs.Content value="nasapplication">
|
||||||
<IVANS />
|
<NASApplication />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="ivans">
|
||||||
|
<IVANS />
|
||||||
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ const yearItems = Array.from(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const years = createListCollection({
|
const years = createListCollection({
|
||||||
items: yearItems.reverse(),
|
items: yearItems.reverse(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const quarters = createListCollection({
|
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} mt={1} border="1px solid" borderRadius="sm" borderColor="gray.500">
|
||||||
<Box m={4} border="1px solid">
|
<Box m={4} border="1px solid">
|
||||||
<Box pl={1} bg="gray.200" border="1px solid" borderColor="gray.400">
|
<Box pl={1} bg={{base:"gray.100", _dark:"gray.600"}} border="1px solid" borderColor="gray.300">
|
||||||
Number of people benefitting from VDAP activities, disaggregate by sex
|
Number of people benefitting from VDAP activities, disaggregate by sex
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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",'© 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,21 +3,23 @@ import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
|||||||
import MapButtonControls from "../CanvasComponents/MapButtonControls";
|
import MapButtonControls from "../CanvasComponents/MapButtonControls";
|
||||||
import MapSettingsButton from "../CanvasComponents/MapSettingsButton";
|
import MapSettingsButton from "../CanvasComponents/MapSettingsButton";
|
||||||
|
|
||||||
export default function GlobalMap() {
|
export default function GlobalMap({markers = []}) {
|
||||||
const position = [45.61142478054226, -122.49630033167544]; // [latitude, longitude]
|
const origin = [45.61142478054226, -122.49630033167544]; // [latitude, longitude]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer center={position} zoom={3} style={{ height: "100%", width: "100%" }}>
|
<MapContainer center={origin} zoom={3} style={{ height: "100%", width: "100%" }}>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
attribution='© OpenStreetMap contributors'
|
attribution='© OpenStreetMap contributors'
|
||||||
/>
|
/>
|
||||||
<MapButtonControls settingsBtn = {<MapSettingsButton />} position="topright" />
|
<MapButtonControls settingsBtn = {<MapSettingsButton />} position="topright" />
|
||||||
<Marker position={position}>
|
{markers.map(m => (
|
||||||
<Popup>
|
<Marker key={m.id} position = {m.position}>
|
||||||
CVO — Home of the volcanoligists
|
<Popup>
|
||||||
</Popup>
|
{m.label}
|
||||||
</Marker>
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { HStack, Flex, Tabs } from "@chakra-ui/react";
|
import { useState } from "react";
|
||||||
|
import { HStack, Flex, Tabs, Box } from "@chakra-ui/react";
|
||||||
import MyDashboard from "./HomeTabs/MyDashboard";
|
import MyDashboard from "./HomeTabs/MyDashboard";
|
||||||
import { SearchBar } from "../CanvasComponents/SearchBar";
|
import { SearchBar } from "../CanvasComponents/SearchBar";
|
||||||
import { SettingsButton } from "../CanvasComponents/SettingsButton";
|
import { SettingsButton } from "../CanvasComponents/SettingsButton";
|
||||||
import { DailyActivity } from "./HomeTabs/DailyActivity";
|
import { DailyActivity } from "./HomeTabs/DailyActivity";
|
||||||
import { Gas } from "./HomeTabs/Gas";
|
import { Gas } from "./HomeTabs/Gas";
|
||||||
import { Seismic } from "./HomeTabs/Seismic";
|
import { Seismic } from "./HomeTabs/Seismic";
|
||||||
|
import { Deformation } from "./HomeTabs/Deformation";
|
||||||
import { RemoteSensing } from "./HomeTabs/RemoteSensing";
|
import { RemoteSensing } from "./HomeTabs/RemoteSensing";
|
||||||
import { CancelButton } from "../CanvasComponents/CancelButton";
|
import { CancelButton } from "../CanvasComponents/CancelButton";
|
||||||
import { SaveButton } from "../CanvasComponents/SaveButton";
|
import { SaveButton } from "../CanvasComponents/SaveButton";
|
||||||
@@ -12,44 +14,103 @@ import DeleteAllButton from "../CanvasComponents/DeleteAllButton";
|
|||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { CanvasHeaderTabs } from '../CanvasComponents/CanvasHeaderTabs';
|
import { CanvasHeaderTabs } from '../CanvasComponents/CanvasHeaderTabs';
|
||||||
import { DASHBOARD_TABS } from "@/constants/viewKeys";
|
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) {
|
export default function Home(props) {
|
||||||
// deconstructing props
|
// deconstructing props
|
||||||
const { view, layout, setLayout, setOriginalLayout, onLayoutChange, onChangeView, onCancel, setOriginalWidgets,
|
const { view, layout, setLayout, onLayoutChange, onChangeView, onCancel, isDelete, setIsDelete,
|
||||||
isDelete, setIsDelete, beginEditIfNeeded, isEditable, setIsEditable, widgetArray, setWidgetArray } = props;
|
beginEditIfNeeded, isEditable, setIsEditable, widgetArray, setWidgetArray } = props;
|
||||||
|
|
||||||
function appendWidget(name, w, h, minw, minh, maxw, maxh) {
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||||
|
const [inspectorTarget, setInspectorTarget] = useState(null);
|
||||||
|
|
||||||
|
const openInspector = ({ id, widgetKey }) => {
|
||||||
|
setInspectorTarget({ id, widgetKey, view });
|
||||||
|
setInspectorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
function appendWidget(widgetKey, overrides = {}) {
|
||||||
beginEditIfNeeded(); // Save state before anything else
|
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 newID = uuid(); // could be replaced with something else like guid
|
||||||
const COLS = 16;
|
const COLS = 16;
|
||||||
const W = w ?? 3;
|
|
||||||
const H = h ?? 3;
|
|
||||||
|
|
||||||
setWidgetArray([ // updating widget array containing info on the types of widgets
|
if (widgetKey === "gas_measurements") {
|
||||||
...widgetArray,
|
setInspectorTarget({ view, id: newID, widgetKey });
|
||||||
{i: newID, widget: name }
|
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)
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
// onAddWidget(newID, w, h, minw, minh, maxw, maxh); // updating array containing info on layout of widgets
|
|
||||||
|
|
||||||
setLayout(prev => [
|
setLayout(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
i: newID,
|
i: newID,
|
||||||
x: (prev.length * W) % COLS,
|
x: (prev.length * w) % COLS,
|
||||||
y: 0,
|
y: 0,
|
||||||
w: w,
|
w,
|
||||||
h: h,
|
h,
|
||||||
minW: minw,
|
minW,
|
||||||
minH: minh,
|
minH,
|
||||||
maxW: maxw,
|
maxW,
|
||||||
maxH: maxh,
|
maxH,
|
||||||
static: false,
|
static: false,
|
||||||
resizeHandles: ['se']
|
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 = () => {
|
const onSave = () => {
|
||||||
console.log("saving widgets...");
|
console.log("saving widgets...");
|
||||||
// Set all widgets back to static: true
|
// Set all widgets back to static: true
|
||||||
@@ -61,8 +122,6 @@ export default function Home(props) {
|
|||||||
);
|
);
|
||||||
setIsEditable(false);
|
setIsEditable(false);
|
||||||
setIsDelete(false);
|
setIsDelete(false);
|
||||||
setOriginalLayout([]); // Clear backup layout
|
|
||||||
setOriginalWidgets([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEditWidgets = () => {
|
const onEditWidgets = () => {
|
||||||
@@ -97,8 +156,11 @@ export default function Home(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prop Forwarding
|
// Prop Forwarding
|
||||||
const MyDashboardProps = { layout, onLayoutChange, isEditable, beginEditIfNeeded, isDelete, DeleteWidget, widgetArray, appendWidget };
|
const MyDashboardProps = { view, layout, onLayoutChange, isEditable,
|
||||||
const settingsButtonProps = { onEditWidgets, appendWidget };
|
beginEditIfNeeded, isDelete, DeleteWidget, widgetArray, appendWidget,
|
||||||
|
openInspector
|
||||||
|
};
|
||||||
|
const settingsButtonProps = { view, onEditWidgets, appendWidget };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* HEADER */
|
/* HEADER */
|
||||||
@@ -150,10 +212,25 @@ export default function Home(props) {
|
|||||||
|
|
||||||
{/* CANVAS */}
|
{/* CANVAS */}
|
||||||
<Tabs.Content value="mydashboard">
|
<Tabs.Content value="mydashboard">
|
||||||
<MyDashboard {...MyDashboardProps}/>
|
<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>
|
||||||
|
|
||||||
<Tabs.Content value="gas">
|
<Tabs.Content value="gas">
|
||||||
<Gas />
|
<MyDashboard {...MyDashboardProps}/>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="seismic">
|
<Tabs.Content value="seismic">
|
||||||
<Seismic />
|
<Seismic />
|
||||||
@@ -164,6 +241,9 @@ export default function Home(props) {
|
|||||||
<Tabs.Content value="daily">
|
<Tabs.Content value="daily">
|
||||||
<DailyActivity />
|
<DailyActivity />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="deformation">
|
||||||
|
<Deformation />
|
||||||
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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,9 +2,13 @@ import GridLayout, { WidthProvider } from 'react-grid-layout';
|
|||||||
import { useSlotRecipe, chakra, Flex } from '@chakra-ui/react';
|
import { useSlotRecipe, chakra, Flex } from '@chakra-ui/react';
|
||||||
import CustomResizeHandle from '../../CanvasComponents/CustomResizeHandle';
|
import CustomResizeHandle from '../../CanvasComponents/CustomResizeHandle';
|
||||||
import FirstWidgetButton from '../../CanvasComponents/FirstWidgetButton';
|
import FirstWidgetButton from '../../CanvasComponents/FirstWidgetButton';
|
||||||
|
import PlaceholderWidget from '../../CanvasComponents/PlaceholderWidget';
|
||||||
|
import { WIDGET_REGISTRY, WIDGET_COMPONENTS } from '@/constants/widgetRegistry';
|
||||||
|
|
||||||
export default function MyDashboard({layout, onLayoutChange, isEditable, isDelete, DeleteWidget, widgetArray, appendWidget}) {
|
export default function MyDashboard({view, layout, onLayoutChange,
|
||||||
console.log("rendering MyDashboard");
|
isEditable, isDelete, DeleteWidget, widgetArray, appendWidget, openInspector
|
||||||
|
}) {
|
||||||
|
//console.log("rendering MyDashboard");
|
||||||
const recipe = useSlotRecipe({ key: "widget" });
|
const recipe = useSlotRecipe({ key: "widget" });
|
||||||
const styles = recipe(); // using root recipe for widget below
|
const styles = recipe(); // using root recipe for widget below
|
||||||
const ResponsiveGridLayout = WidthProvider(GridLayout); // canvas width now adjusts to window size
|
const ResponsiveGridLayout = WidthProvider(GridLayout); // canvas width now adjusts to window size
|
||||||
@@ -12,8 +16,8 @@ export default function MyDashboard({layout, onLayoutChange, isEditable, isDelet
|
|||||||
const handleDragStop = () => { document.body.style.userSelect = ""; }; // allow text highlighting all other times
|
const handleDragStop = () => { document.body.style.userSelect = ""; }; // allow text highlighting all other times
|
||||||
let merged = [];
|
let merged = [];
|
||||||
|
|
||||||
console.log("widgetArray: ", widgetArray);
|
//console.log("widgetArray: ", widgetArray);
|
||||||
console.log("layout: ", layout);
|
//console.log("layout: ", layout);
|
||||||
|
|
||||||
if (widgetArray.length && layout.length) {
|
if (widgetArray.length && layout.length) {
|
||||||
const widgetMap = new Map(widgetArray.map(item => [item.i, item]));
|
const widgetMap = new Map(widgetArray.map(item => [item.i, item]));
|
||||||
@@ -36,7 +40,7 @@ export default function MyDashboard({layout, onLayoutChange, isEditable, isDelet
|
|||||||
bg="gray.150"
|
bg="gray.150"
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
>
|
>
|
||||||
<FirstWidgetButton appendWidget={ appendWidget } />
|
<FirstWidgetButton view={view} appendWidget={ appendWidget } />
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,11 +63,36 @@ export default function MyDashboard({layout, onLayoutChange, isEditable, isDelet
|
|||||||
position="relative"
|
position="relative"
|
||||||
resizeHandle={<CustomResizeHandle isVisable={isEditable} />}
|
resizeHandle={<CustomResizeHandle isVisable={isEditable} />}
|
||||||
>
|
>
|
||||||
{ merged.map((item) => {
|
{merged.map((item) => {
|
||||||
const Widget = item.widget;
|
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";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex key={item.i} css={styles.root} boxShadow="md" height="100%" >
|
<Flex key={item.i} css={styles.root} boxShadow="md" height="100%" >
|
||||||
<Widget id={item.i} isEditable={isEditable} isDelete={isDelete} DeleteWidget={DeleteWidget} appendWidget={appendWidget}/>
|
<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
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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>0–70% Complete</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex align="center" gap="2">
|
||||||
|
<Image src={yellowVolcano} boxSize="18px" alt="yellow volcano"/>
|
||||||
|
<Text>70–100% 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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,62 +8,127 @@ import DialogPopup from './Canvas/CanvasComponents/DialogPopup.jsx';
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const recipe = useRecipe({ key: "dashboard" });
|
const recipe = useRecipe({ key: "dashboard" });
|
||||||
const styles = recipe();
|
const styles = recipe();
|
||||||
|
|
||||||
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 [layout, setLayout] = useState([]);
|
|
||||||
const [widgetArray, setWidgetArray] = useState([]);
|
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
|
||||||
|
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 beginEditIfNeeded = () => {
|
const beginEditIfNeeded = () => {
|
||||||
if ( !isEditable ) {
|
if (!isCanvasView) return;
|
||||||
setOriginalLayout(layout);
|
updateDash(view, s => {
|
||||||
setOriginalWidgets(widgetArray);
|
if(s.isEditable) return s; // already editing
|
||||||
}
|
return {
|
||||||
|
...s,
|
||||||
|
originalLayout: s.layout,
|
||||||
|
originalWidgets: s.widgetArray,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
console.log("canceling changes...");
|
if (!isCanvasView) return;
|
||||||
console.log(originalWidgets);
|
|
||||||
if (originalLayout.length) {
|
updateDash(view, s => ({
|
||||||
setLayout(originalLayout.map(item => ({
|
...s,
|
||||||
...item,
|
layout: (s.originalLayout?.length ? s.originalLayout : s.layout).map(it => ({
|
||||||
static: true
|
...it,
|
||||||
})));
|
static: true,
|
||||||
};
|
})),
|
||||||
setWidgetArray(originalWidgets);
|
widgetArray: s.originalWidgets ?? s.widgetArray,
|
||||||
setIsEditable(false);
|
isEditable: false,
|
||||||
setIsDelete(false);
|
isDelete: false,
|
||||||
setOriginalLayout([]);
|
originalLayout: [],
|
||||||
setOriginalWidgets([]);
|
originalWidgets: [],
|
||||||
}
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLayoutChange = (newLayout) => {
|
||||||
|
setLayout(newLayout);
|
||||||
|
};
|
||||||
|
|
||||||
// Prevent navigation while in Edit mode without saving or cancelling
|
|
||||||
const handleViewChange = (nextView) => {
|
const handleViewChange = (nextView) => {
|
||||||
console.log("1. handling view change")
|
// prevent nav only when current view is a widget canvas AND you're editing
|
||||||
if (view === "mydashboard" && isEditable) {
|
if (CANVAS_VIEWS.has(view) && dash.isEditable) {
|
||||||
console.log("2. currently editing");
|
|
||||||
dialog.setOpen(true);
|
dialog.setOpen(true);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// const confirmed = window.confirm(
|
|
||||||
// "You have unsaved changes. Discard them and continue?"
|
|
||||||
// );
|
|
||||||
// if (!confirmed) return;
|
|
||||||
// onCancel();
|
|
||||||
}
|
}
|
||||||
setView(nextView);
|
setView(nextView);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLayoutChange = (newLayout) => setLayout(newLayout);
|
|
||||||
|
|
||||||
// Prop Forwarding
|
// Prop Forwarding
|
||||||
const canvasProps = { view, layout, setLayout, setOriginalLayout, onLayoutChange, onChangeView: handleViewChange,
|
const canvasProps = {
|
||||||
onCancel, isEditable, setIsEditable, isDelete, setIsDelete, widgetArray, setWidgetArray, originalWidgets, setOriginalWidgets, beginEditIfNeeded
|
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 sidebarProps = { view, onChangeView: handleViewChange, darkMode, setDarkMode };
|
const sidebarProps = { view, onChangeView: handleViewChange, darkMode, setDarkMode };
|
||||||
const headerProps = { onChangeView: handleViewChange };
|
const headerProps = { onChangeView: handleViewChange };
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import { chakra, useRecipe, Box, Button, Stack, Text, Flex } from "@chakra-ui/react"
|
import { chakra, useRecipe, Box, Button, Stack, Text, Flex, Image } from "@chakra-ui/react"
|
||||||
|
import { useColorMode } from "@/components/ui/color-mode";
|
||||||
import UserAvatarMenu from "./UserAvatarMenu.jsx";
|
import UserAvatarMenu from "./UserAvatarMenu.jsx";
|
||||||
|
|
||||||
//TODO: Dynamically populate per each user
|
//TODO: Dynamically populate per each user
|
||||||
const user = {
|
const user = {
|
||||||
name: "venessa kuchenik",
|
name: "Dan Hansen",
|
||||||
email: "vkuchenik@contractor.usgs.gov",
|
email: "dshansen@contractor.usgs.gov",
|
||||||
// avatar: "src\\assets\\user_profile.svg"
|
// avatar: "src\\assets\\user_profile.svg"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ onChangeView }) {
|
export default function Header({ onChangeView }) {
|
||||||
const recipe = useRecipe({ key: "header" });
|
const recipe = useRecipe({ key: "header" });
|
||||||
const styles = recipe();
|
const styles = recipe();
|
||||||
|
const { colorMode } = useColorMode();
|
||||||
|
|
||||||
|
const logoSource =
|
||||||
|
colorMode === "dark"
|
||||||
|
? "/images/NVIS_Logo_Black.png"
|
||||||
|
: "/images/NVIS_Logo_transparent.png"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<chakra.div css={styles}>
|
<chakra.div css={styles}>
|
||||||
<Button onClick={() => onChangeView("mydashboard")} color="color" variant="plain" textStyle="5xl" fontWeight="bold">VDAP</Button>
|
<Button
|
||||||
|
onClick={() => onChangeView("mydashboard")}
|
||||||
|
// color="color"
|
||||||
|
variant="plain"
|
||||||
|
// textStyle="5xl"
|
||||||
|
// fontWeight="bold"
|
||||||
|
>
|
||||||
|
<Image src={ logoSource } alt="NVIS Logo" h="60px" />
|
||||||
|
</Button>
|
||||||
<Flex align="center" gap={3} pr={6}>
|
<Flex align="center" gap={3} pr={6}>
|
||||||
<Stack gap="0" textAlign="center" cursor="default">
|
<Stack gap="0" textAlign="center" cursor="default">
|
||||||
<Text color="color" fontWeight="bold" textStyle="lg">{user.name}</Text>
|
<Text color="color" fontWeight="bold" textStyle="lg">{user.name}</Text>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function Sidebar({ view, onChangeView, darkMode, setDarkMode }) {
|
|||||||
|
|
||||||
// Track currrent view from both Sidebar and Canvas when 'view' changes
|
// Track currrent view from both Sidebar and Canvas when 'view' changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("Current view = ", view);
|
//console.log("Current view = ", view);
|
||||||
const newIndex = buttons.findIndex((btn) => {
|
const newIndex = buttons.findIndex((btn) => {
|
||||||
if (btn.view === "mydashboard") {
|
if (btn.view === "mydashboard") {
|
||||||
return DASHBOARD_VIEWS.includes(view);
|
return DASHBOARD_VIEWS.includes(view);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react'
|
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react'
|
||||||
import { ThemeProvider, useTheme } from 'next-themes'
|
import { ThemeProvider, useTheme } from 'next-themes'
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { LuMoon, LuSun } from 'react-icons/lu'
|
import { LuMoon, LuSun } from 'react-icons/lu'
|
||||||
@@ -18,6 +19,12 @@ export function useColorMode() {
|
|||||||
const toggleColorMode = () => {
|
const toggleColorMode = () => {
|
||||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
useEffect(() => {
|
||||||
|
//console.log(`Color mode changed to: ${colorMode}`);
|
||||||
|
}, [colorMode]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
colorMode: colorMode,
|
colorMode: colorMode,
|
||||||
setColorMode: setTheme,
|
setColorMode: setTheme,
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
// Main views called from Home tab -> Canvas navigation header
|
// 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 = [
|
export const DASHBOARD_TABS = [
|
||||||
{ value: "mydashboard", label: "My Dashboard" },
|
{ value: "mydashboard", label: "My Dashboard", isCanvas: true },
|
||||||
{ value: "gas", label: "Gas" },
|
{ value: "gas", label: "Gas", isCanvas: true },
|
||||||
{ value: "seismic", label: "Seismic" },
|
{ value: "seismic", label: "Seismic", isCanvas: false },
|
||||||
{ value: "remote", label: "Remote Sensing" },
|
{ value: "remote", label: "Remote Sensing", isCanvas: false },
|
||||||
{ value: "daily", label: "Daily Activity" },
|
{ value: "deformation", label: "Deformation", isCanvas: false },
|
||||||
|
{ value: "daily", label: "Daily Activity", isCanvas: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 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
|
// Volcano views called from Volcano tab -> Canvas navigation header
|
||||||
export const VOLCANO_TABS = [
|
export const VOLCANO_TABS = [
|
||||||
{ value: "volcanohome", label: "Volcano Home" },
|
{ value: "volcanohome", label: "Volcano Home" },
|
||||||
@@ -19,6 +28,7 @@ export const VOLCANO_TABS = [
|
|||||||
// Admin views called from Admin tab -> Canvas navigation header
|
// Admin views called from Admin tab -> Canvas navigation header
|
||||||
export const ADMIN_TABS = [
|
export const ADMIN_TABS = [
|
||||||
{ value: "medataentry", label: "M&E Data Entry" },
|
{ value: "medataentry", label: "M&E Data Entry" },
|
||||||
|
{ value: "nvews", label: "NVEWS" },
|
||||||
{ value: "projectcollector", label: "Project Collector" },
|
{ value: "projectcollector", label: "Project Collector" },
|
||||||
{ value: "nasapplication", label: "NAS Application" },
|
{ value: "nasapplication", label: "NAS Application" },
|
||||||
{ value: "ivans", label: "IVANS" },
|
{ value: "ivans", label: "IVANS" },
|
||||||
@@ -41,6 +51,7 @@ export const VIEW_LABELS = {
|
|||||||
global: "Global Map",
|
global: "Global Map",
|
||||||
regional: "Regional Map",
|
regional: "Regional Map",
|
||||||
volcano: "Volcano",
|
volcano: "Volcano",
|
||||||
|
deformation: "Deformation",
|
||||||
|
|
||||||
// Volcano navigation tabs
|
// Volcano navigation tabs
|
||||||
volcanohome: "Volcano Home",
|
volcanohome: "Volcano Home",
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 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);
|
||||||
|
};
|
||||||
@@ -72,7 +72,7 @@ const widgetRecipe = defineSlotRecipe({
|
|||||||
borderColor: "{base300}",
|
borderColor: "{base300}",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
position: "fixed"
|
//position: "fixed"
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexShrink: "0",
|
flexShrink: "0",
|
||||||
@@ -138,18 +138,21 @@ const config = defineConfig({
|
|||||||
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
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: {
|
_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-control-attribution': {
|
||||||
|
maxWidth: "100%",
|
||||||
_dark: {
|
_dark: {
|
||||||
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'.leaflet-container': {
|
'.leaflet-container': {
|
||||||
fontFamily: "georgia, system-ui, sans-serif !important",
|
fontFamily: "georgia, system-ui, sans-serif !important",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
_dark: {
|
_dark: {
|
||||||
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 157 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 554 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 157 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 155 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 774 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 255 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?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
	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
	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
	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
	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
	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
	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
	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>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 158 B |
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
// },
|
||||||
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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" };
|
||||||
|
}
|
||||||