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