diff --git a/reactapp/App.scss b/reactapp/App.scss index b609462..8c4a116 100644 --- a/reactapp/App.scss +++ b/reactapp/App.scss @@ -124,9 +124,16 @@ $theme-colors: ( --chart-crosshair-color: #4b5563; --chart-glyph-stroke-color: #ffffff; --chart-empty-text-color: #6b7280; + /* comma-separated line colors */ --chart-line-colors: #1d4ed8, #f97316, #16a34a, #dc2626, #7c3aed; + --chart-line-0: #1d4ed8; + --chart-line-1: #f97316; + --chart-line-2: #16a34a; + --chart-line-3: #dc2626; + --chart-line-4: #7c3aed; + } /* Dark theme override */ @@ -219,7 +226,11 @@ $theme-colors: ( --chart-empty-text-color: #9ca3af; --chart-line-colors: #2dd4bf, #f97316, #a855f7, #38bdf8, #facc15; - + --chart-line-0: #2dd4bf; + --chart-line-1: #f97316; + --chart-line-2: #a855f7; + --chart-line-3: #38bdf8; + --chart-line-4: #facc15; } } diff --git a/reactapp/features/DataStream/components/forecast/Plot.js b/reactapp/features/DataStream/components/forecast/Plot.js index 0d8eb5f..efcc0e0 100644 --- a/reactapp/features/DataStream/components/forecast/Plot.js +++ b/reactapp/features/DataStream/components/forecast/Plot.js @@ -1,10 +1,9 @@ import React, { useCallback, useMemo, useEffect, useRef, useId } from 'react'; import { Group } from '@visx/group'; import { scaleLinear, scaleTime } from '@visx/scale'; -import { AxisLeft, AxisBottom } from '@visx/axis'; +import { AxisBottom } from '@visx/axis'; import { LinePath, Line } from '@visx/shape'; import { extent, bisector } from 'd3-array'; -import { GridRows, GridColumns } from '@visx/grid'; import { useTooltip, TooltipWithBounds, defaultStyles } from '@visx/tooltip'; import { localPoint } from '@visx/event'; import { GlyphCircle } from '@visx/glyph'; @@ -13,11 +12,17 @@ import { RectClipPath } from '@visx/clip-path'; import { getVariableUnits } from '../../lib/data'; import useDataStreamStore from '../../store/Datastream'; import useTimeSeriesStore from 'features/DataStream/store/Timeseries'; - +import { ChartContainer, NoData } from '../styles/Styles'; const MARGIN = Object.freeze({ top: 40, right: 20, bottom: 30, left: 50 }); +const axisLabelColor = 'var(--chart-axis-label-color, #111827)'; +const axisTickTextColor = 'var(--chart-axis-tick-text-color, #111827)'; +const tooltipBg = 'var(--chart-tooltip-bg, rgba(255, 255, 255, 0.95))'; +const tooltipTextColor = 'var(--chart-tooltip-text, #111827)'; +const tooltipBorderColor = 'var(--chart-tooltip-border-color, rgba(148, 163, 184, 0.6))'; +const crosshairColor = 'var(--chart-crosshair-color, #4b5563)'; +const glyphStrokeColor = 'var(--chart-glyph-stroke-color, #ffffff)'; -/** -------------------- Static SVG layer (no tooltip props) -------------------- */ const StaticSvgLayer = React.memo(function StaticSvgLayer({ width, height, @@ -26,29 +31,18 @@ const StaticSvgLayer = React.memo(function StaticSvgLayer({ innerHeight, clipId, - // colors/theme axisLabelColor, - axisStrokeColor, - axisTickColor, - axisTickTextColor, - gridColor, colors, - // label + scales yAxisLabel, xScale, yScale, xTickValues, formatDate, - - // series + accessors data, getDate, getYValue, - - // memoized axis props axisLabelProps, - leftTickLabelProps, bottomTickLabelProps, }) { return ( @@ -62,40 +56,15 @@ const StaticSvgLayer = React.memo(function StaticSvgLayer({ - {/* - */} - - @@ -186,38 +155,17 @@ const LineChart = React.memo(function LineChart({ width, height, data, layout }) const fontSize = screenWidth <= 1300 ? 13 : 18; const fontWeight = screenWidth <= 1300 ? 600 : 500; - const rootStyles = getComputedStyle(document.documentElement); - const axisLabelColor = - rootStyles.getPropertyValue('--chart-axis-label-color').trim() || '#111827'; - const axisStrokeColor = - rootStyles.getPropertyValue('--chart-axis-stroke-color').trim() || '#111827'; - const axisTickColor = - rootStyles.getPropertyValue('--chart-axis-tick-color').trim() || '#111827'; - const axisTickTextColor = - rootStyles.getPropertyValue('--chart-axis-tick-text-color').trim() || '#111827'; - const gridColor = - rootStyles.getPropertyValue('--chart-grid-color').trim() || '#e5e7eb'; - const tooltipBg = - rootStyles.getPropertyValue('--chart-tooltip-bg').trim() || 'rgba(255, 255, 255, 0.95)'; - const tooltipTextColor = - rootStyles.getPropertyValue('--chart-tooltip-text').trim() || '#111827'; - const tooltipBorderColor = - rootStyles.getPropertyValue('--chart-tooltip-border-color').trim() || - 'rgba(148, 163, 184, 0.6)'; - const crosshairColor = - rootStyles.getPropertyValue('--chart-crosshair-color').trim() || '#4b5563'; - const glyphStrokeColor = - rootStyles.getPropertyValue('--chart-glyph-stroke-color').trim() || '#ffffff'; - const noDataTextColor = - rootStyles.getPropertyValue('--chart-empty-text-color').trim() || '#6b7280'; - - const lineColorsVar = rootStyles.getPropertyValue('--chart-line-colors').trim(); + + const colors = useMemo( - () => - lineColorsVar - ? lineColorsVar.split(/\s*,\s*/) - : ['#1d4ed8', '#f97316', '#16a34a', '#dc2626', '#7c3aed'], - [lineColorsVar] + () => [ + 'var(--chart-line-0, #1d4ed8)', + 'var(--chart-line-1, #f97316)', + 'var(--chart-line-2, #16a34a)', + 'var(--chart-line-3, #dc2626)', + 'var(--chart-line-4, #7c3aed)', + ], + [] ); // axis props (stable references) @@ -445,22 +393,12 @@ const LineChart = React.memo(function LineChart({ width, height, data, layout }) const noData = !hasData; return ( -
+ {noData ? ( -
🛠 No data to display -
+ ) : ( <> {/* STATIC layer (won't rerender on tooltip changes) */} @@ -472,10 +410,6 @@ const LineChart = React.memo(function LineChart({ width, height, data, layout }) innerHeight={innerHeight} clipId={clipId} axisLabelColor={axisLabelColor} - axisStrokeColor={axisStrokeColor} - axisTickColor={axisTickColor} - axisTickTextColor={axisTickTextColor} - gridColor={gridColor} colors={colors} yAxisLabel={yAxisLabel} xScale={xScale} @@ -552,7 +486,7 @@ const LineChart = React.memo(function LineChart({ width, height, data, layout }) )} )} -
+ ); }); diff --git a/reactapp/features/DataStream/components/forecast/cacheTable.js b/reactapp/features/DataStream/components/forecast/cacheTable.js index f6af2d4..a730570 100644 --- a/reactapp/features/DataStream/components/forecast/cacheTable.js +++ b/reactapp/features/DataStream/components/forecast/cacheTable.js @@ -1,37 +1,68 @@ -import React, { Fragment, useCallback } from 'react'; +import React, { Fragment, useCallback, useState } from 'react'; import { IoFolderOpenOutline, IoClose, IoSkullOutline } from "react-icons/io5"; -import { IconLabel, Title, SButton } from '../styles/Styles'; +import { IconLabel, Title, SButton } from '../styles/Styles'; import { useCacheTablesStore } from 'features/DataStream/store/CacheTables'; -export const CacheTable = React.memo(({tables}) => { +export const CacheTable = React.memo(({ tables }) => { const deleteCacheTable = useCacheTablesStore((state) => state.delete_cacheTable); const resetCacheTables = useCacheTablesStore((state) => state.reset); - + + // ✅ local loading state + const [deletingAll, setDeletingAll] = useState(false); + const [deletingId, setDeletingId] = useState(null); // table id currently deleting + const deleteSingleCache = useCallback( - async (tableId) => { - console.log("Delete cache table:", tableId); - deleteCacheTable(tableId); - }, - [deleteCacheTable] + async (tableId) => { + if (deletingAll || deletingId) return; // avoid concurrent deletes + console.log("Delete cache table:", tableId); + setDeletingId(tableId); + try { + await deleteCacheTable(tableId); + } finally { + setDeletingId(null); + } + }, + [deleteCacheTable, deletingAll, deletingId] ); - + const deleteAllCache = useCallback(async () => { - console.log("Delete all cache tables"); - resetCacheTables(); - }, [resetCacheTables]); + if (deletingAll || deletingId) return; + console.log("Delete all cache tables"); + setDeletingAll(true); + try { + await resetCacheTables(); + } finally { + setDeletingAll(false); + } + }, [resetCacheTables, deletingAll, deletingId]); + const disableAllButtons = deletingAll || deletingId != null; return ( Files Loaded - deleteAllCache()} > + + - + + {/* optional tiny status */} + {deletingAll && ( + + Deleting... + + )} - + {tables && tables.length > 0 ? ( @@ -42,54 +73,71 @@ export const CacheTable = React.memo(({tables}) => { - {tables.map((table, index) => ( - - + - - + + - - ))} + + + ); + })}
{ + const isDeletingThis = deletingId === table.id || deletingAll; + + return ( +
- { - deleteSingleCache(table.id); - }}> + > + deleteSingleCache(table.id)} + disabled={disableAllButtons} + aria-busy={isDeletingThis} + title={isDeletingThis ? 'Deleting...' : 'Delete table'} + style={{ opacity: disableAllButtons ? 0.6 : 1, cursor: disableAllButtons ? 'not-allowed' : 'pointer' }} + > + {/* optional: show X or text while deleting */} + {isDeletingThis ? ( + ... + ) : ( + )} - - {table.name || 'Unknown'} - + + + {table.name || 'Unknown'} + + > {table.size || 'N/A'} -
) : ( -
No cached tables available.
+
+ No cached tables available. +
)}
); diff --git a/reactapp/features/DataStream/components/forecast/dataMenu.js b/reactapp/features/DataStream/components/forecast/dataMenu.js index 1e4658d..1d77da9 100644 --- a/reactapp/features/DataStream/components/forecast/dataMenu.js +++ b/reactapp/features/DataStream/components/forecast/dataMenu.js @@ -8,6 +8,7 @@ import { getCacheKey } from 'features/DataStream/lib/opfsCache'; import useTimeSeriesStore from 'features/DataStream/store/Timeseries'; import useDataStreamStore from 'features/DataStream/store/Datastream'; import useS3DataStreamBucketStore from 'features/DataStream/store/s3Store'; +import { useFeatureStore } from 'features/DataStream/store/Layers'; import { useShallow } from 'zustand/react/shallow'; import { ModelIcon, @@ -89,9 +90,11 @@ const DataMenuControls = React.memo(function DataMenuControls() { set_cache_key: state.set_cache_key, })) ); - - const feature_id = useTimeSeriesStore((s) => s.feature_id); - + const { selected_feature_id } = useFeatureStore( + useShallow((s) => ({ + selected_feature_id: s.selected_feature ? s.selected_feature._id : null, + })) + ); const { availableModelsList, availableDatesList, @@ -130,7 +133,7 @@ const DataMenuControls = React.memo(function DataMenuControls() { const handleVisulization = useEvent(async () => { const { loading, set_loading_text } = useTimeSeriesStore.getState(); - if (!feature_id || !vpu) { + if (!selected_feature_id || !vpu) { set_loading_text('Please select a feature on the map first'); set_loading_text(''); return; @@ -145,11 +148,13 @@ const DataMenuControls = React.memo(function DataMenuControls() { set_loading_text(''); return; } - + // reset(); const cacheKey = getCacheKey(model, date, forecast, cycle, ensemble, vpu, outputFile); + console.log('Generated cache key:', cacheKey); set_cache_key(cacheKey); const _prefix = makePrefix(model, date, forecast, cycle, ensemble, vpu, outputFile); + console.log('Generated S3 prefix:', _prefix); set_prefix(_prefix); }); diff --git a/reactapp/features/DataStream/components/forecast/variablesMenu.js b/reactapp/features/DataStream/components/forecast/variablesMenu.js index 2f1c273..356f5d7 100644 --- a/reactapp/features/DataStream/components/forecast/variablesMenu.js +++ b/reactapp/features/DataStream/components/forecast/variablesMenu.js @@ -1,9 +1,10 @@ -import React, { useMemo, Fragment, useCallback } from 'react'; +import React, { useMemo, Fragment, useCallback, useEffect, useRef } from 'react'; import { Row, IconLabel } from '../styles/Styles'; import SelectComponent from '../SelectComponent'; -import { getTimeseries} from 'features/DataStream/lib/queryData'; +import { getTimeseries, getVpuVariableFlat } from 'features/DataStream/lib/queryData'; import useTimeSeriesStore from 'features/DataStream/store/Timeseries'; import useDataStreamStore from 'features/DataStream/store/Datastream'; +import { useVPUStore } from 'features/DataStream/store/Layers'; import { useShallow } from 'zustand/react/shallow'; import { makeTitle } from 'features/DataStream/lib/utils'; import { @@ -11,6 +12,14 @@ import { } from 'features/DataStream/lib/layers'; function VariablesMenu() { + const isMountedRef = useRef(true); + const requestIdRef = useRef(0); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); const{ forecast, variables, cacheKey } = useDataStreamStore( useShallow((state) => ({ @@ -30,6 +39,12 @@ function VariablesMenu() { })) ); + const { setVarData } = useVPUStore( + useShallow((s) => ({ + setVarData: s.setVarData, + })) + ); + const availableVariablesList = useMemo(() => { return variables.map((v) => ({ value: v, label: v })); }, [variables]); @@ -42,20 +57,45 @@ function VariablesMenu() { const handleChangeVariable = useCallback(async (evt) => { const opt = evt || availableVariablesList?.[0]; - if (opt) set_variable(opt.value); + if (!opt || !feature_id) return; + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; const id = feature_id.split('-')[1]; - const series = await getTimeseries(id, cacheKey, opt.value); - const xy = series.map((d) => ({ - x: new Date(d.time), - y: d[opt.value], + + try { + const flat = await getVpuVariableFlat(cacheKey, opt.value); + if (!isMountedRef.current || requestId !== requestIdRef.current) return; + setVarData(opt.value, flat); + + set_variable(opt.value); + const series = await getTimeseries(id, cacheKey, opt.value); + if (!isMountedRef.current || requestId !== requestIdRef.current) return; + + const xy = series.map((d) => ({ + x: new Date(d.time), + y: d[opt.value], })); - set_series(xy); - set_layout({ - 'yaxis': opt.value, - 'xaxis': "Time", - 'title': makeTitle(forecast, feature_id), - }); - }, [availableVariablesList]); + set_series(xy); + set_layout({ + yaxis: opt.value, + xaxis: 'Time', + title: makeTitle(forecast, feature_id), + }); + } catch (err) { + if (!isMountedRef.current || requestId !== requestIdRef.current) return; + console.error('Failed to change variable', err); + } + }, [ + availableVariablesList, + cacheKey, + feature_id, + forecast, + setVarData, + set_variable, + set_series, + set_layout, + ]); return ( @@ -74,4 +114,4 @@ function VariablesMenu() { } -export default React.memo(VariablesMenu); \ No newline at end of file +export default React.memo(VariablesMenu); diff --git a/reactapp/features/DataStream/components/map/LayersControl.js b/reactapp/features/DataStream/components/map/LayersControl.js index b598f7c..113ae1c 100644 --- a/reactapp/features/DataStream/components/map/LayersControl.js +++ b/reactapp/features/DataStream/components/map/LayersControl.js @@ -5,12 +5,11 @@ import { IoLayers } from "react-icons/io5"; import { MdInfoOutline } from "react-icons/md"; import { IconLabel, Row, Title, SButton} from '../styles/Styles'; import { NexusSymbol, CatchmentSymbol, FlowPathSymbol, GaugeSymbol, symbologyColors, CursorSymbol } from '../../lib/layers'; -import useTheme from 'hooks/useTheme'; import { LayerInfoModal } from '../Modals'; +import { useTheme } from 'styled-components'; export const LayerControl = () => { - const theme = useTheme(); - + const theme = useTheme() const [modalLayerInfoShow, setModalLayerInfoShow] = useState(false); const nexusLayer = useLayersStore((state) => state.nexus); diff --git a/reactapp/features/DataStream/components/map/Mapg.js b/reactapp/features/DataStream/components/map/Mapg.js index 9ba3a21..8e6c217 100644 --- a/reactapp/features/DataStream/components/map/Mapg.js +++ b/reactapp/features/DataStream/components/map/Mapg.js @@ -58,21 +58,37 @@ const MainMap = () => { enabledHovering: s.hovered_enabled, })) ); + const { selectedFeatureId, loading, set_feature_id } = useTimeSeriesStore( + useShallow((s) => ({ + selectedFeatureId: s.feature_id, + loading: s.loading, + set_feature_id: s.set_feature_id, + })) + ); - const selectedFeatureId = useTimeSeriesStore((state) => state.feature_id); - const loading = useTimeSeriesStore((state) => state.loading); - const set_loading_text = useTimeSeriesStore((state) => state.set_loading_text); - const set_feature_id = useTimeSeriesStore((state) => state.set_feature_id); - const reset = useTimeSeriesStore((state) => state.reset); - const nexus_pmtiles = useDataStreamStore((state) => state.nexus_pmtiles); - const conus_pmtiles = useDataStreamStore((state) => state.community_pmtiles); + const { + nexus_pmtiles, + conus_pmtiles, + vpu, + set_vpu, + } = useDataStreamStore( + useShallow((s) => ({ + nexus_pmtiles: s.nexus_pmtiles, + conus_pmtiles: s.community_pmtiles, + vpu: s.vpu, + set_vpu: s.set_vpu, + })) + ); - const set_vpu = useDataStreamStore((state) => state.set_vpu); - const set_hovered_feature = useFeatureStore((state) => state.set_hovered_feature); - const hovered_feature = useFeatureStore((state) => state.hovered_feature); - const set_selected_feature = useFeatureStore((state) => state.set_selected_feature); - const selectedMapFeature = useFeatureStore((state) => state.selected_feature); + const { set_hovered_feature, set_selected_feature, selectedMapFeature, hovered_feature } = useFeatureStore( + useShallow((s) => ({ + set_hovered_feature: s.set_hovered_feature, + set_selected_feature: s.set_selected_feature, + selectedMapFeature: s.selected_feature, + hovered_feature: s.hovered_feature, + })) + ); const { currentTimeIndex, variable } = useTimeSeriesStore( @@ -93,6 +109,7 @@ const MainMap = () => { const EMPTY_LAYERS = useMemo(() => [], []); const mapRef = useRef(null); + const hoverMapRef = useRef(null); const lastSigRef = useRef(""); const pathDataRef = useRef([]); @@ -102,12 +119,12 @@ const MainMap = () => { const deckLayers = useMemo(() => { if (!isFlowPathsVisible) return EMPTY_LAYERS; - + // console.log('Rendering flow paths layer'); const varData = valuesByVar; const numTimes = timesArr?.length || 0; const pathData = pathDataRef.current; - + if (!varData || !numTimes || !pathData?.length) return EMPTY_LAYERS; const bounds = computeBounds(varData); return [ @@ -147,18 +164,63 @@ const MainMap = () => { ]); + const hoverLayers = useMemo(() => ["divides", "nexus-points"], []); + + const isMapUsable = useCallback((map) => { + if (!map || typeof map.on !== "function" || typeof map.off !== "function") return false; + if (typeof map.getCanvas !== "function") return false; + try { + return !!map.getCanvas(); + } catch { + return false; + } + }, []); + + const setPointerCursor = useCallback((e) => { + const canvas = e?.target?.getCanvas?.(); + if (canvas?.style) canvas.style.cursor = "pointer"; + }, []); + + const resetPointerCursor = useCallback((e) => { + const canvas = e?.target?.getCanvas?.(); + if (canvas?.style) canvas.style.cursor = ""; + }, []); + + const removeHoverListeners = useCallback((map) => { + if (!isMapUsable(map)) return; + hoverLayers.forEach((layer) => { + map.off("mouseenter", layer, setPointerCursor); + map.off("mouseleave", layer, resetPointerCursor); + }); + }, [hoverLayers, isMapUsable, setPointerCursor, resetPointerCursor]); + const handleMapLoad = useCallback((event) => { const map = event.target; - - // keep your existing onMapLoad behavior - const hoverLayers = ["divides", "nexus-points"]; + if (!isMapUsable(map)) return; + + if (hoverMapRef.current && hoverMapRef.current !== map) { + removeHoverListeners(hoverMapRef.current); + } + + // De-dupe in case onLoad fires multiple times for the same map instance. + removeHoverListeners(map); hoverLayers.forEach((layer) => { - map.on("mouseenter", layer, () => (map.getCanvas().style.cursor = "pointer")); - map.on("mouseleave", layer, () => (map.getCanvas().style.cursor = "")); + map.on("mouseenter", layer, setPointerCursor); + map.on("mouseleave", layer, resetPointerCursor); }); + hoverMapRef.current = map; + reorderLayers(map); - }, []); + }, [hoverLayers, isMapUsable, removeHoverListeners, resetPointerCursor, setPointerCursor]); + + useEffect(() => { + return () => { + removeHoverListeners(hoverMapRef.current); + hoverMapRef.current = null; + }; + }, [removeHoverListeners]); + const onHover = useCallback((event) => { if (!enabledHovering) return; @@ -325,7 +387,6 @@ const MainMap = () => { if (loading) { return; } - // reset(); const map = event.target; @@ -338,16 +399,21 @@ const MainMap = () => { for (const feature of features) { const layerId = feature.layer.id; + const featureIdProperty = layerIdToFeatureType(layerId); + const unbiased_id = feature.properties[featureIdProperty]; + const {lon, lat} = getCentroid(feature); set_selected_feature({ latitude: lat, longitude: lon, + layerId: layerId, + _id: unbiased_id, ...feature.properties, }); - const featureIdProperty = layerIdToFeatureType(layerId); - const unbiased_id = feature.properties[featureIdProperty]; - set_feature_id(unbiased_id); const vpu_str = `VPU_${feature.properties.vpuid}`; + if (vpu_str === vpu){ + set_feature_id(unbiased_id); + } set_vpu(vpu_str); break; } @@ -384,4 +450,3 @@ const MainMap = () => { const MapComponent = React.memo(MainMap); export default MapComponent; - diff --git a/reactapp/features/DataStream/components/map/SearchBar.js b/reactapp/features/DataStream/components/map/SearchBar.js index c4cefaa..f4797ec 100644 --- a/reactapp/features/DataStream/components/map/SearchBar.js +++ b/reactapp/features/DataStream/components/map/SearchBar.js @@ -4,15 +4,30 @@ import { loadIndexData, getFeatureProperties } from 'features/DataStream/lib/que import useTimeSeriesStore from 'features/DataStream/store/Timeseries'; import useDataStreamStore from 'features/DataStream/store/Datastream'; import {useFeatureStore} from 'features/DataStream/store/Layers'; +import {useShallow} from 'zustand/react/shallow'; const SearchBar = ({ placeholder = 'Search for an id' }) => { - const hydrofabric_index_url = useDataStreamStore((state) => state.hydrofabric_index); - const set_vpu = useDataStreamStore((state) => state.set_vpu); + const { hydrofabric_index_url, vpu, set_vpu } = useDataStreamStore( + useShallow((s) => ({ + hydrofabric_index_url: s.hydrofabric_index, + vpu: s.vpu, + set_vpu: s.set_vpu, + })) + ); - const feature_id = useTimeSeriesStore((state) => state.feature_id); - const set_feature_id = useTimeSeriesStore((state) => state.set_feature_id); - const set_selected_feature = useFeatureStore((state) => state.set_selected_feature); + const { feature_id, set_feature_id } = useTimeSeriesStore( + useShallow((s) => ({ + feature_id: s.feature_id, + set_feature_id: s.set_feature_id, + })) + ); + + const { set_selected_feature } = useFeatureStore( + useShallow((s) => ({ + set_selected_feature: s.set_selected_feature, + })) + ); const handleChange = async (e) => { const unbiased_id = e.target.value; @@ -21,10 +36,16 @@ const SearchBar = ({ placeholder = 'Search for an id' }) => { return }; const feature = features.length > 0 ? features[0] : null; - set_selected_feature(feature || null); + + set_selected_feature({ + _id: unbiased_id, + ...feature, + }); const vpu_str = `VPU_${feature.vpuid}`; + if (vpu_str === vpu){ + set_feature_id(unbiased_id); + } set_vpu(vpu_str); - set_feature_id(unbiased_id); } useEffect(() => { diff --git a/reactapp/features/DataStream/components/menus/ForecastMenu.js b/reactapp/features/DataStream/components/menus/ForecastMenu.js index 642170b..f30c45b 100644 --- a/reactapp/features/DataStream/components/menus/ForecastMenu.js +++ b/reactapp/features/DataStream/components/menus/ForecastMenu.js @@ -4,20 +4,26 @@ import VariablesMenu from '../forecast/variablesMenu'; import { Content, Container } from '../styles/Styles'; import TimeSeriesCard from '../forecast/TimeseriesCard'; import useTimeSeriesStore from 'features/DataStream/store/Timeseries'; -import { useVPUStore } from 'features/DataStream/store/Layers'; +import { useVPUStore, useFeatureStore } from 'features/DataStream/store/Layers'; import { ForecastHeader } from '../forecast/ForecastHeader'; import { FeatureInformation } from '../forecast/FeatureInformation'; import { TimeSlider } from '../forecast/TimeSlider'; import { useShallow } from 'zustand/react/shallow'; const ForecastMenu = () => { - const { feature_id, layout, reset } = useTimeSeriesStore( + const { layout, reset } = useTimeSeriesStore( useShallow((state) => ({ feature_id: state.feature_id, layout: state.layout, reset: state.reset, })) ); + const { feature_id } = useFeatureStore( + useShallow((state) => ({ + feature_id: state.selected_feature ? state.selected_feature._id : null, + })) + ); + const { resetVPU } = useVPUStore( useShallow((state) => ({ resetVPU: state.resetVPU, diff --git a/reactapp/features/DataStream/components/styles/Styles.js b/reactapp/features/DataStream/components/styles/Styles.js index 251e8ef..3e32a3c 100644 --- a/reactapp/features/DataStream/components/styles/Styles.js +++ b/reactapp/features/DataStream/components/styles/Styles.js @@ -410,3 +410,20 @@ export const ViewContainer = styled.div` flex-direction: column; overflow: hidden; `; + +export const ChartContainer = styled.div` + position: relative; + border-radius: 10px; + overflow: hidden; +`; + +export const NoData = styled.div` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-style: italic; + font-size: 1rem; + color: var(--chart-empty-text-color, #6b7280); +`; \ No newline at end of file diff --git a/reactapp/features/DataStream/lib/layers.js b/reactapp/features/DataStream/lib/layers.js index e3d1f1c..2339e52 100644 --- a/reactapp/features/DataStream/lib/layers.js +++ b/reactapp/features/DataStream/lib/layers.js @@ -466,8 +466,8 @@ export function valueToColor(value, bounds) { ]; if (value === null || value === undefined || value <= -9998) return [100, 100, 100, 150]; if (!bounds || bounds.max === bounds.min) return colorScale[0]; - - const t = Math.max(0, Math.min(1, (value - bounds.min) / (bounds.max - bounds.min))); + const t = Math.pow((value - bounds.min) / (bounds.max - bounds.min), 0.5) + // const t = Math.max(0, Math.min(1, (value - bounds.min) / (bounds.max - bounds.min))); const idx = t * (colorScale.length - 1); const lower = Math.floor(idx); const upper = Math.ceil(idx); diff --git a/reactapp/features/DataStream/lib/opfsCache.js b/reactapp/features/DataStream/lib/opfsCache.js index 0795b85..39fc736 100644 --- a/reactapp/features/DataStream/lib/opfsCache.js +++ b/reactapp/features/DataStream/lib/opfsCache.js @@ -1,4 +1,6 @@ const CACHE_DIR = "nrds-arrow-cache"; +let cacheDirPromise = null; + function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; @@ -12,8 +14,20 @@ async function getCacheDir() { // OPFS not supported (e.g., non-Chromium / http) return null; } - const root = await navigator.storage.getDirectory(); - return await root.getDirectoryHandle(CACHE_DIR, { create: true }); + + if (!cacheDirPromise) { + cacheDirPromise = (async () => { + const root = await navigator.storage.getDirectory(); + return await root.getDirectoryHandle(CACHE_DIR, { create: true }); + })(); + } + + try { + return await cacheDirPromise; + } catch (e) { + cacheDirPromise = null; + throw e; + } } export async function saveArrowToCache(key, buffer) { @@ -31,7 +45,7 @@ export async function saveArrowToCache(key, buffer) { dataToWrite = new Uint8Array(buffer); } else if (ArrayBuffer.isView(buffer)) { // covers Uint8Array, DataView, etc. - dataToWrite = new Uint8Array(buffer.buffer); + dataToWrite = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); } else if (buffer instanceof Blob) { dataToWrite = buffer; } else { @@ -59,27 +73,14 @@ export async function loadArrowFromCache(key) { } } -async function* getFilesRecursively(entry) { - if (entry.kind === "file") { - const file = await entry.getFile(); - if (file !== null) { - yield file; - } - } else if (entry.kind === "directory") { - for await (const handle of entry.values()) { - yield* getFilesRecursively(handle); - } - } -} - - export async function getFilesFromCache() { const dir = await getCacheDir(); if (!dir) return null; const files = []; - const fileHandlers = await getFilesRecursively(dir); - - for await (const file of fileHandlers) { + + for await (const handle of dir.values()) { + if (handle.kind !== "file") continue; + const file = await handle.getFile(); const id = decodeURIComponent(file.name.replace(".arrow", "")); files.push({id: id, name: id.replaceAll("_", "/"), size: formatBytes(file.size)}); } diff --git a/reactapp/features/DataStream/lib/queryData.js b/reactapp/features/DataStream/lib/queryData.js index ad26c41..81ad357 100644 --- a/reactapp/features/DataStream/lib/queryData.js +++ b/reactapp/features/DataStream/lib/queryData.js @@ -5,28 +5,45 @@ import { saveArrowToCache, loadArrowFromCache } from "./opfsCache"; import { getConnection } from "./duckdbClient"; import { getNCFiles } from "./s3Utils"; +const DEBUG = process.env.NODE_ENV !== "production"; +const debugLog = (...args) => { + if (DEBUG) console.log(...args); +}; + export async function getTimeseries(id, cacheKey, variable) { const conn = await getConnection(); try { - const q = await conn.query(` + const rows = []; + const stream = await conn.send(` SELECT time, ${variable} FROM ${cacheKey} WHERE feature_id = ${id} ORDER BY time `); - console.log("Query executed:", ` + debugLog("Query executed:", ` SELECT time, ${variable} FROM ${cacheKey} WHERE feature_id = ${id} ORDER BY time `); - const rows = q.toArray().map(Object.fromEntries); - rows.columns = q.schema.fields.map((d) => d.name); + for await (const batch of stream) { + const times = batch.getChild('time'); + const values = batch.getChildAt(1); + if (!times || !values) continue; + + const n = Math.min(times.length, values.length); + for (let i = 0; i < n; i++) { + rows.push({ + time: times.get(i), + [variable]: values.get(i), + }); + } + } - console.log( - `[getTimeseries] (literal) id=${id} rows=${rows.length}`, rows + debugLog( + `[getTimeseries] (literal) id=${id} rows=${rows.length}` ); return rows; } finally { @@ -35,23 +52,29 @@ export async function getTimeseries(id, cacheKey, variable) { } export async function getFeatureIDs(cacheKey) { - console.log("getFeatureIDs called with cacheKey:", cacheKey); + debugLog("getFeatureIDs called with cacheKey:", cacheKey); const conn = await getConnection(); try { - const q = await conn.query(` + const featureIds = []; + const stream = await conn.send(` SELECT feature_id FROM "${cacheKey}" `); - - const rows = q.toArray().map(Object.fromEntries); - rows.columns = q.schema.fields.map((d) => d.name); - console.log( - `[updateDataInfo] (literal) rows=${rows.length}` + for await (const batch of stream) { + const ids = batch.getChild('feature_id'); + if (!ids) continue; + for (let i = 0; i < ids.length; i++) { + featureIds.push(ids.get(i)); + } + } + + debugLog( + `[updateDataInfo] (literal) rows=${featureIds.length}` ); - return rows; + return featureIds; } finally { await conn.close(); } @@ -59,7 +82,7 @@ export async function getFeatureIDs(cacheKey) { export async function loadIndexData({ remoteUrl }) { const cacheKey = "index_data_table"; - console.log("loadIndexData called with cacheKey:", cacheKey); + debugLog("loadIndexData called with cacheKey:", cacheKey); const conn = await getConnection(); @@ -77,7 +100,7 @@ export async function loadIndexData({ remoteUrl }) { const exists = rows[0].cnt > 0; if (exists) { - console.log(`Table "${cacheKey}" already exists, skipping load.`); + debugLog(`Table "${cacheKey}" already exists, skipping load.`); return; } @@ -89,7 +112,7 @@ export async function loadIndexData({ remoteUrl }) { SELECT * FROM read_parquet('${remoteUrl}') `); - console.log(`Created table "${cacheKey}" from remote parquet ${remoteUrl}`); + debugLog(`Created table "${cacheKey}" from remote parquet ${remoteUrl}`); } finally { await conn.close(); } @@ -97,24 +120,38 @@ export async function loadIndexData({ remoteUrl }) { export async function getFeatureProperties({ cacheKey, feature_id }) { - console.log("getFeature called with cacheKey:", cacheKey, "feature_id:", feature_id); + debugLog("getFeature called with cacheKey:", cacheKey, "feature_id:", feature_id); const conn = await getConnection(); try { - const q = await conn.query(` + const stream = await conn.send(` SELECT * FROM "${cacheKey}" WHERE id = '${feature_id}' + LIMIT 1 `); - const rows = q.toArray().map(Object.fromEntries); - rows.columns = q.schema.fields.map((d) => d.name); + for await (const batch of stream) { + if (!batch.numRows) continue; + + const row = {}; + for (let i = 0; i < batch.schema.fields.length; i++) { + const field = batch.schema.fields[i]; + const col = batch.getChildAt(i); + row[field.name] = col ? col.get(0) : null; + } + + debugLog( + `[getFeatureProperties] (literal) id=${feature_id} rows=1` + ); + return [row]; + } - console.log( - `[getFeatureProperties] (literal) id=${feature_id} rows=${rows.length}` + debugLog( + `[getFeatureProperties] (literal) id=${feature_id} rows=0` ); - return rows; + return []; } finally { await conn.close(); } @@ -126,7 +163,7 @@ export async function loadVpuData( prefix, vpu_gpkg ) { - console.log("loadVpuData called with cacheKey:", cacheKey); + debugLog("loadVpuData called with cacheKey:", cacheKey); let buffer = await loadArrowFromCache(cacheKey); let fileSize; @@ -157,14 +194,14 @@ export async function loadVpuData( if (!exists) { await conn.insertArrowTable(arrowTable, { name: cacheKey }); } else { - console.log( + debugLog( `Table "${cacheKey}" already exists, skipping insertArrowTable.` ); } } finally { await conn.close(); - return fileSize; } + return fileSize; } export async function checkForTable(cacheKey) { @@ -189,7 +226,7 @@ export async function deleteTable(tableName){ await conn.query(` DROP TABLE IF EXISTS "${tableName}" `); - console.log(`Table ${tableName} has been deleted.`); + debugLog(`Table ${tableName} has been deleted.`); } finally { await conn.close(); } @@ -211,7 +248,7 @@ export async function dropAllVpuDataTables() { const rows = result.toArray(); if (!rows.length) { - console.log('No VPU cache tables found to drop (excluding index_data_table).'); + debugLog('No VPU cache tables found to drop (excluding index_data_table).'); return; } @@ -220,12 +257,12 @@ export async function dropAllVpuDataTables() { const name = row.table_name; const fullName = `"${schema}"."${name}"`; - console.log(`Dropping table ${fullName}...`); + debugLog(`Dropping table ${fullName}...`); await conn.query(`DROP TABLE IF EXISTS ${fullName}`); } - console.log('Finished dropping VPU cache tables (index_data_table preserved).'); + debugLog('Finished dropping VPU cache tables (index_data_table preserved).'); } finally { await conn.close(); } @@ -233,12 +270,13 @@ export async function dropAllVpuDataTables() { export async function getVariables({ cacheKey }) { - console.log("getVariables called with cacheKey:", cacheKey); + debugLog("getVariables called with cacheKey:", cacheKey); const conn = await getConnection(); try { - const q = await conn.query(` + const cols = []; + const stream = await conn.send(` SELECT column_name FROM information_schema.columns WHERE table_name = '${cacheKey}' @@ -247,30 +285,39 @@ export async function getVariables({ cacheKey }) { ) `); - const rows = q.toArray(); - const cols = rows.map((r) => r.column_name); + for await (const batch of stream) { + const names = batch.getChild('column_name'); + if (!names) continue; + for (let i = 0; i < names.length; i++) { + cols.push(names.get(i)); + } + } + return cols; } finally { await conn.close(); } } - -function rowsToObjects(q) { - // matches your pattern: q.toArray().map(Object.fromEntries) - return q.toArray().map(Object.fromEntries); -} - export async function getDistinctFeatureIds(cacheKey) { const conn = await getConnection(); try { - const q = await conn.query(` + const featureIds = []; + const stream = await conn.send(` SELECT DISTINCT feature_id FROM "${cacheKey}" ORDER BY feature_id `); - const rows = rowsToObjects(q); - return rows.map((r) => r.feature_id); + + for await (const batch of stream) { + const ids = batch.getChild('feature_id'); + if (!ids) continue; + for (let i = 0; i < ids.length; i++) { + featureIds.push(ids.get(i)); + } + } + + return featureIds; } finally { await conn.close(); } @@ -279,13 +326,22 @@ export async function getDistinctFeatureIds(cacheKey) { export async function getDistinctTimes(cacheKey) { const conn = await getConnection(); try { - const q = await conn.query(` + const times = []; + const stream = await conn.send(` SELECT DISTINCT time FROM "${cacheKey}" ORDER BY time `); - const rows = rowsToObjects(q); - return rows.map((r) => r.time); + + for await (const batch of stream) { + const t = batch.getChild('time'); + if (!t) continue; + for (let i = 0; i < t.length; i++) { + times.push(t.get(i)); + } + } + + return times; } finally { await conn.close(); } @@ -295,16 +351,41 @@ export async function getDistinctTimes(cacheKey) { export async function getVpuVariableFlat(cacheKey, variable) { const conn = await getConnection(); try { - const q = await conn.query(` + const countResult = await conn.query(` + SELECT COUNT(*) AS n + FROM "${cacheKey}" + `); + const countCol = countResult.getChild('n'); + const totalRows = Number(countCol?.get(0) ?? 0); + if (!Number.isFinite(totalRows) || totalRows <= 0) { + return new Float32Array(); + } + + const out = new Float32Array(totalRows); + let offset = 0; + + const stream = await conn.send(` SELECT ${variable} AS v FROM "${cacheKey}" ORDER BY feature_id, time `); - const rows = rowsToObjects(q); - return Float32Array.from(rows.map((r) => Number(r.v))); + + for await (const batch of stream) { + const values = batch.getChild('v'); + if (!values) continue; + for (let i = 0; i < values.length; i++) { + out[offset++] = Number(values.get(i)); + } + } + + if (offset === out.length) return out; + // Defensive resize in case rows changed during stream. + const resized = new Float32Array(offset); + for (let i = 0; i < offset; i++) { + resized[i] = out[i]; + } + return resized; } finally { await conn.close(); } } - - diff --git a/reactapp/features/DataStream/lib/s3Utils.js b/reactapp/features/DataStream/lib/s3Utils.js index da485ce..1966f14 100644 --- a/reactapp/features/DataStream/lib/s3Utils.js +++ b/reactapp/features/DataStream/lib/s3Utils.js @@ -85,10 +85,7 @@ export const makePrefix = (model, avail_date,ngen_forecast,ngen_cycle, ngen_ense return prefix_path; } -// export function getNCFiles(model, date, forecast, cycle, time, vpu, outputFile) { export function getNCFiles(prefix) { - // const prefix = makePrefix(model, date, forecast, cycle, time, vpu); - // const ncFileParsed = `s3://ciroh-community-ngen-datastream/${prefix}${outputFile}`; const ncFileParsed = `s3://ciroh-community-ngen-datastream/${prefix}`; return ncFileParsed } @@ -99,27 +96,32 @@ export const makeGpkgUrl = (vpu) => { } export const initialS3Data = async(vpu, { signal } = {}) => { - let _models = await getOptionsFromURL(`outputs`, { signal }); - if (_models.length === 0){ - return {models: [], dates: [], forecasts: [], cycles: [], outputFiles: []}; - } - const models = _models.filter(m => m.value !== 'test'); - const dates = (await getOptionsFromURL(`outputs/${models[0]?.value}/v2.2_hydrofabric/`, { signal })).reverse(); - if (dates.length === 0){ - return {models, dates: [], forecasts: [], cycles: [], outputFiles: []}; - } - const forecasts = (await getOptionsFromURL(`outputs/${models[0]?.value}/v2.2_hydrofabric/${dates[1]?.value}/`, { signal })).reverse(); - if (forecasts.length === 0){ - return {models, dates, forecasts: [], cycles: [], outputFiles: []}; - } - const cycles = await getOptionsFromURL(`outputs/${models[0]?.value}/v2.2_hydrofabric/${dates[1]?.value}/${forecasts[0]?.value}/`, { signal }); - if (cycles.length === 0){ - return {models, dates, forecasts, cycles: [], outputFiles: []}; - } - if (!vpu) { - return {models, dates, forecasts, cycles, outputFiles: []}; + try{ + let _models = await getOptionsFromURL(`outputs`, { signal }); + if (_models.length === 0){ + return {models: [], dates: [], forecasts: [], cycles: [], ensembles: [], outputFiles: []}; + } + const models = _models.filter(m => m.value !== 'test'); + const dates = (await getOptionsFromURL(`outputs/${models[0]?.value}/v2.2_hydrofabric/`, { signal })).reverse(); + if (dates.length === 0){ + return {models, dates: [], forecasts: [], cycles: [], ensembles:[], outputFiles: []}; + } + const forecasts = (await getOptionsFromURL(`outputs/${models[0]?.value}/v2.2_hydrofabric/${dates[1]?.value}/`, { signal })).reverse(); + if (forecasts.length === 0){ + return {models, dates, forecasts: [], cycles: [], ensembles:[], outputFiles: []}; + } + const cycles = await getOptionsFromURL(`outputs/${models[0]?.value}/v2.2_hydrofabric/${dates[1]?.value}/${forecasts[0]?.value}/`, { signal }); + if (cycles.length === 0){ + return {models, dates, forecasts, cycles: [], ensembles:[], outputFiles: []}; + } + if (!vpu) { + return {models, dates, forecasts, cycles, ensembles:[], outputFiles: []}; + } + const outputFiles = await getOptionsFromURL(`outputs/${models[0]?.value}/v2.2_hydrofabric/${dates[1]?.value}/${forecasts[0]?.value}/${cycles[0]?.value}/${vpu}/ngen-run/outputs/troute/`, { signal }); + return {models, dates, forecasts, cycles, ensembles:[], outputFiles}; + }catch(error){ + throw error; } - const outputFiles = await getOptionsFromURL(`outputs/${models[0]?.value}/v2.2_hydrofabric/${dates[1]?.value}/${forecasts[0]?.value}/${cycles[0]?.value}/${vpu}/ngen-run/outputs/troute/`, { signal }); - return {models, dates, forecasts, cycles, outputFiles}; + } diff --git a/reactapp/features/DataStream/store/CacheTables.js b/reactapp/features/DataStream/store/CacheTables.js index 4d777bb..297e7e4 100644 --- a/reactapp/features/DataStream/store/CacheTables.js +++ b/reactapp/features/DataStream/store/CacheTables.js @@ -1,31 +1,53 @@ -import {create} from 'zustand'; +import { create } from 'zustand'; import { deleteFileFromCache, clearCache } from '../lib/opfsCache'; import { deleteTable, dropAllVpuDataTables } from '../lib/queryData'; +import { terminateDatabase } from '../lib/duckdbClient'; -const EMPTY_TABLE = [] +const EMPTY_TABLE = []; export const useCacheTablesStore = create((set) => ({ - cacheTables: EMPTY_TABLE, - add_cacheTable: (newCacheTable) => set((state) => ({ - cacheTables: [...state.cacheTables, newCacheTable], + cacheTables: EMPTY_TABLE, + + add_cacheTable: (newCacheTable) => + set((state) => ({ + cacheTables: [...state.cacheTables, newCacheTable], })), - delete_cacheTable: async (tableId) => - { - await deleteFileFromCache(tableId); - await deleteTable(tableId); - set( - (state) => ({ - cacheTables: state.cacheTables.filter( - (table) => table.id !== tableId - ), - }) - ) - }, - reset: async () => { - await dropAllVpuDataTables(); - await clearCache(); - set({ cacheTables: EMPTY_TABLE }); - }, - - set_cacheTables: (newCacheTables) => set({ cacheTables: newCacheTables }), -})); \ No newline at end of file + + delete_cacheTable: async (tableId) => { + // best-effort: don't abort if one step fails + await deleteFileFromCache(tableId).catch((e) => { + console.warn('[cacheTables] deleteFileFromCache failed:', tableId, e); + }); + + await deleteTable(tableId).catch((e) => { + console.warn('[cacheTables] deleteTable failed:', tableId, e); + }); + + set((state) => ({ + cacheTables: state.cacheTables.filter((table) => table.id !== tableId), + })); + + return true; + }, + + reset: async () => { + // best-effort: attempt both regardless of failures + await dropAllVpuDataTables().catch((e) => { + console.warn('[cacheTables] dropAllVpuDataTables failed:', e); + }); + + await clearCache().catch((e) => { + console.warn('[cacheTables] clearCache failed:', e); + }); + + // Release worker/database memory; next query will lazily recreate the DB. + await terminateDatabase().catch((e) => { + console.warn('[cacheTables] terminateDatabase failed:', e); + }); + + set({ cacheTables: EMPTY_TABLE }); + return true; + }, + + set_cacheTables: (newCacheTables) => set({ cacheTables: newCacheTables }), +})); diff --git a/reactapp/features/DataStream/store/Layers.js b/reactapp/features/DataStream/store/Layers.js index 933c6c8..8228606 100644 --- a/reactapp/features/DataStream/store/Layers.js +++ b/reactapp/features/DataStream/store/Layers.js @@ -33,6 +33,8 @@ const buildFeatureIdToIndex = (featureIds) => { const featureKey = (f) => f?.id ?? f?.properties?.id ?? f?.properties?.feature_id ?? null; +const MAX_CACHED_VARS = 3; + export const useLayersStore = create( subscribeWithSelector((set, get) => ({ nexus: { visible: false }, @@ -120,6 +122,7 @@ export const useVPUStore = create( featureIdToIndex: {}, times: [], valuesByVar: {}, + varDataOrder: [], // Optional convenience getters getVarData: (variable) => get().valuesByVar?.[variable], @@ -160,15 +163,31 @@ export const useVPUStore = create( setVarData: (variable, flatValues) => set((s) => { const prev = s.valuesByVar?.[variable]; + let nextOrder = [...s.varDataOrder.filter((v) => v !== variable), variable]; + let nextValuesByVar = + prev === flatValues ? s.valuesByVar : { ...s.valuesByVar, [variable]: flatValues }; + + if (nextOrder.length > MAX_CACHED_VARS) { + const evicted = nextOrder.slice(0, nextOrder.length - MAX_CACHED_VARS); + nextOrder = nextOrder.slice(-MAX_CACHED_VARS); + + let copied = nextValuesByVar !== s.valuesByVar; + for (const key of evicted) { + if (!Object.prototype.hasOwnProperty.call(nextValuesByVar, key)) continue; + if (!copied) { + nextValuesByVar = { ...nextValuesByVar }; + copied = true; + } + delete nextValuesByVar[key]; + } + } - // if same reference, no update (most important guard) - if (prev === flatValues) return s; - - const nextValuesByVar = { ...s.valuesByVar, [variable]: flatValues }; - - if (shallowEqualObj(s.valuesByVar, nextValuesByVar)) return s; + const sameOrder = sameArrayRefOrValues(s.varDataOrder, nextOrder); + const sameValues = + nextValuesByVar === s.valuesByVar || shallowEqualObj(s.valuesByVar, nextValuesByVar); + if (sameOrder && sameValues) return s; - return { valuesByVar: nextValuesByVar }; + return { valuesByVar: nextValuesByVar, varDataOrder: nextOrder }; }), resetVPU: () => @@ -178,11 +197,18 @@ export const useVPUStore = create( s.featureIds.length === 0 && s.times.length === 0 && Object.keys(s.featureIdToIndex).length === 0 && - Object.keys(s.valuesByVar).length === 0 + Object.keys(s.valuesByVar).length === 0 && + s.varDataOrder.length === 0 ) { return s; } - return { featureIds: [], times: [], featureIdToIndex: emptyObj, valuesByVar: emptyObj }; + return { + featureIds: [], + times: [], + featureIdToIndex: emptyObj, + valuesByVar: emptyObj, + varDataOrder: [], + }; }), })) -); \ No newline at end of file +); diff --git a/reactapp/features/DataStream/store/Timeseries.js b/reactapp/features/DataStream/store/Timeseries.js index 2362403..c2b6958 100644 --- a/reactapp/features/DataStream/store/Timeseries.js +++ b/reactapp/features/DataStream/store/Timeseries.js @@ -112,7 +112,7 @@ const useTimeSeriesStore = create( reset_series: () => set((s) => { if (s.series === EMPTY_SERIES && s.currentTimeIndex === 0 && s.isPlaying === false) return s; - return { series: EMPTY_SERIES, currentTimeIndex: 0, isPlaying: false }; + return { series: EMPTY_SERIES, currentTimeIndex: 0, isPlaying: false}; }), reset: () => diff --git a/reactapp/features/DataStream/views/DatastreamView.js b/reactapp/features/DataStream/views/DatastreamView.js index a88aa15..86878e1 100644 --- a/reactapp/features/DataStream/views/DatastreamView.js +++ b/reactapp/features/DataStream/views/DatastreamView.js @@ -6,7 +6,7 @@ import MainMenu from 'features/DataStream/components/menus/MainMenu'; import useDataStreamStore from 'features/DataStream/store/Datastream'; import useTimeSeriesStore from '../store/Timeseries'; import { useCacheTablesStore } from '../store/CacheTables'; -import { useVPUStore } from '../store/Layers'; +import { useVPUStore, useFeatureStore } from '../store/Layers'; import useS3DataStreamBucketStore from 'features/DataStream/store/s3Store'; import { initialS3Data, makePrefix, makeGpkgUrl } from 'features/DataStream/lib/s3Utils'; import { getCacheKey } from 'features/DataStream/lib/opfsCache'; @@ -19,20 +19,13 @@ import { checkForTable, getVpuVariableFlat, getVariables } from 'features/DataStream/lib/queryData'; +import { terminateDatabase } from 'features/DataStream/lib/duckdbClient'; import { makeTitle } from 'features/DataStream/lib/utils'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useShallow } from "zustand/react/shallow"; function InitialS3Loader() { - // const { vpu, ensemble, setAllState } = useDataStreamStore( - // useShallow((s) => ({ - // vpu: s.vpu, - // ensemble: s.ensemble, - // setAllState: s.setAllState, - // })) - // ); - - const { vpu, ensemble } = useDataStreamStore( + const { vpu } = useDataStreamStore( useShallow((s) => ({ vpu: s.vpu, ensemble: s.ensemble, @@ -61,7 +54,7 @@ function InitialS3Loader() { async function fetchInitialData() { if (!vpu) return; try { - const { models, dates, forecasts, cycles, outputFiles } = + const { models, dates, forecasts, cycles, ensembles, outputFiles } = await initialS3Data(vpu, { signal: controller.signal }); if (!alive) return; // <- prevents any setState after unmount/dep change @@ -73,7 +66,7 @@ function InitialS3Loader() { dates[1]?.value, forecasts[0]?.value, cycles[0]?.value, - ensemble, + ensembles[0]?.value || null, vpu, outputFiles[0]?.value ); @@ -83,24 +76,15 @@ function InitialS3Loader() { set_cycle(cycles[0]?.value); set_outputFile(outputFiles[0]?.value); set_date(dates[1]?.value); - set_ensemble(ensemble); + set_ensemble(ensembles[0]?.value || null); set_cache_key(cacheKey); - // setAllState({ - // model: _models[0]?.value, - // date: dates[1]?.value, - // forecast: forecasts[0]?.value, - // cycle: cycles[0]?.value, - // ensemble: null, - // outputFile: outputFiles[0]?.value, - // cache_key: cacheKey, - // }); const _prefix = makePrefix( _models[0]?.value, dates[1]?.value, forecasts[0]?.value, cycles[0]?.value, - ensemble, + ensembles[0]?.value || null, vpu, outputFiles[0]?.value ); @@ -115,12 +99,11 @@ function InitialS3Loader() { }); } catch (error) { - // fetch abort throws DOMException with name AbortError if (error?.name === 'AbortError') return; console.error('Error fetching initial S3 data:', error); } } - + fetchInitialData(); return () => { @@ -133,15 +116,21 @@ function InitialS3Loader() { } function TimeseriesLoader() { - const { cacheKey, outputFile, forecast, vpu, set_variables } = useDataStreamStore( + + const { cacheKey, forecast, vpu, set_variables } = useDataStreamStore( useShallow((s) => ({ cacheKey: s.cache_key, - outputFile: s.outputFile, forecast: s.forecast, vpu: s.vpu, set_variables: s.set_variables, })) ); + const { selected_feature_id } = useFeatureStore( + useShallow((s) => ({ + selected_feature_id: s.selected_feature ? s.selected_feature._id : null, + })) + ); + const { add_cacheTable } = useCacheTablesStore( useShallow((s) => ({ add_cacheTable: s.add_cacheTable, @@ -152,90 +141,150 @@ function TimeseriesLoader() { useShallow((s) => ({ prefix: s.prefix })) ); - const { feature_id, loading, variable, set_variable, set_loading_text, set_series, set_layout, set_loading } = useTimeSeriesStore( + const { feature_id, loading, variable, set_feature_id, set_variable, set_loading_text, set_series, set_layout, set_loading, reset_series, reset } = useTimeSeriesStore( useShallow((s) => ({ feature_id: s.feature_id, loading: s.loading, variable: s.variable, + set_feature_id: s.set_feature_id, set_variable: s.set_variable, set_loading_text: s.set_loading_text, set_series: s.set_series, set_layout: s.set_layout, set_loading: s.set_loading, + reset_series: s.reset_series, + reset: s.reset, })) ); - const { set_feature_ids, setVarData, setAnimationIndex } = useVPUStore( + const { set_feature_ids, setVarData, setAnimationIndex, resetVPU } = useVPUStore( useShallow((s) => ({ set_feature_ids: s.set_feature_ids, setVarData: s.setVarData, setAnimationIndex: s.setAnimationIndex, + resetVPU: s.resetVPU, })) ); + useEffect(() => { + let alive = true; - useEffect( () => { - async function getData(){ - if (!outputFile || loading || !feature_id ) return; + async function getTsData(){ + if (!feature_id || loading ) return; + console.log('Loading timeseries for feature_id:', feature_id, 'variable:', variable, 'cacheKey:', cacheKey); + reset_series(); + const id = feature_id.split('-')[1]; + set_loading(true); + set_loading_text('Loading feature properties...'); + let currentVariable = variable; + try { + const series = await getTimeseries(id, cacheKey, currentVariable); + if (!alive) return; + + const xy = series.map((d) => ({ + x: new Date(d.time), + y: d[currentVariable], + })); + set_loading_text(`Loaded ${xy.length} points for id: ${id}`); + set_series(xy); + set_layout({ + yaxis: currentVariable, + xaxis: '', + title: makeTitle(forecast, feature_id), + }); + set_loading_text(''); + } + catch (err) { + if (!alive) return; + set_loading_text(`Failed to load timeseries for id: ${feature_id}`); + console.error('Failed to load timeseries for', feature_id, err); + } finally { + if (!alive) return; + set_loading(false); + } + } + getTsData(); + + return () => { + alive = false; + }; + }, [feature_id]); + + useEffect( () => { + let alive = true; + + async function getVPUData(){ + if (!cacheKey || loading ) return; + console.log('Loading VPU data for cacheKey:', cacheKey); + reset(); + resetVPU(); const vpu_gpkg = makeGpkgUrl(vpu); - const id = feature_id.split('-')[1]; set_loading(true); set_loading_text('Loading feature properties...'); let currentVariable = variable; try { const tableExists = await checkForTable(cacheKey); + if (!alive) return; + if (!tableExists) { try{ const fileSize = await loadVpuData(cacheKey, prefix, vpu_gpkg); + if (!alive) return; add_cacheTable({id: cacheKey, name: cacheKey.replaceAll('_',' '), size: fileSize}); }catch(err){ + if (!alive) return; console.error('No data for VPU', vpu, err); set_loading_text('No data available for selected VPU'); - set_loading(false); - } - const featureIDs = await getFeatureIDs(cacheKey); - set_feature_ids(featureIDs); - const variables = await getVariables({ cacheKey }); - set_variables(variables); - set_variable(variables[0]); - currentVariable = variables[0]; - const [featureIds, times, flat] = await Promise.all([ - getDistinctFeatureIds(cacheKey), - getDistinctTimes(cacheKey), - getVpuVariableFlat(cacheKey, currentVariable), - ]); - setAnimationIndex(featureIds, times); - setVarData(currentVariable, flat); - } - const series = await getTimeseries(id, cacheKey, currentVariable); - const xy = series.map((d) => ({ - x: new Date(d.time), - y: d[currentVariable], - })); - set_loading_text(`Loaded ${xy.length} points for id: ${id}`); - set_series(xy); - set_layout({ - yaxis: currentVariable, - xaxis: '', - title: makeTitle(forecast, feature_id), - }); - set_loading_text(''); - set_loading(false); + return; + } + } + const featureIDs = await getFeatureIDs(cacheKey); + if (!alive) return; + set_feature_ids(featureIDs); + const variables = await getVariables({ cacheKey }); + if (!alive) return; + set_variables(variables); + set_variable(variables[0]); + currentVariable = variables[0]; + const [featureIds, times, flat] = await Promise.all([ + getDistinctFeatureIds(cacheKey), + getDistinctTimes(cacheKey), + getVpuVariableFlat(cacheKey, currentVariable), + ]); + if (!alive) return; + setAnimationIndex(featureIds, times); + setVarData(currentVariable, flat); + set_feature_id(selected_feature_id); + + set_loading_text(''); } catch (err) { - set_loading_text(`Failed to load timeseries for id: ${id}`); - set_loading(false); - console.error('Failed to load timeseries for', id, err); + if (!alive) return; + set_loading_text(`Failed to load VPU data for cacheKey: ${cacheKey}`); + console.error('Failed to load VPU data for cacheKey:', cacheKey, err); + } finally { + if (!alive) return; + set_loading(false); } } - getData(); - - }, [cacheKey, feature_id]); + getVPUData(); + return () => { + alive = false; + }; + }, [cacheKey]); return null; } const DataStreamView = () => { + useEffect(() => { + return () => { + void terminateDatabase().catch((err) => { + console.warn('Failed to terminate DuckDB worker on DataStreamView unmount:', err); + }); + }; + }, []); + return ( diff --git a/reactapp/features/Tethys/components/loader/Loader.js b/reactapp/features/Tethys/components/loader/Loader.js index cf508c1..575fa14 100644 --- a/reactapp/features/Tethys/components/loader/Loader.js +++ b/reactapp/features/Tethys/components/loader/Loader.js @@ -7,40 +7,50 @@ import { AppContext } from 'features/Tethys/context/context'; const APP_ID = process.env.TETHYS_APP_ID; const LOADER_DELAY = process.env.TETHYS_LOADER_DELAY; +const LOADER_DELAY_MS = Number(LOADER_DELAY) || 0; function Loader({children}) { const [error, setError] = useState(null); const [isLoaded, setIsLoaded] = useState(false); const [appContext, setAppContext] = useState(null); - - const handleError = (error) => { - // Delay setting the error to avoid flashing the loading animation - setTimeout(() => { - setError(error); - }, LOADER_DELAY); - }; - useEffect(() => { - // Get the session first - tethysAPI.getSession() - .then(() => { - // Then load all other app data - Promise.all([ - tethysAPI.getAppData(APP_ID), - tethysAPI.getUserData(), - tethysAPI.getCSRF(), - ]) - .then(([tethysApp, user, csrf]) => { - // Update app context - setAppContext({tethysApp, user, csrf}); + useEffect(() => { + let active = true; + const timeoutIds = []; + const schedule = (callback) => { + const timeoutId = setTimeout(() => { + if (active) callback(); + }, LOADER_DELAY_MS); + timeoutIds.push(timeoutId); + }; + const handleError = (nextError) => { + // Delay setting the error to avoid flashing the loading animation + schedule(() => { + setError(nextError); + }); + }; + Promise.all([ + tethysAPI.getAppData(APP_ID), + tethysAPI.getUserData(), + tethysAPI.getJWTToken(), + tethysAPI.getCSRF(), + ]) + .then(([tethysApp, user, jwt, csrf]) => { + // Update app context + if (!active) return; + setAppContext({tethysApp, user, jwt, csrf}); - // Allow for minimum delay to display loader - setTimeout(() => { - setIsLoaded(true) - }, LOADER_DELAY); - }) - .catch(handleError); - }).catch(handleError); + // Allow for minimum delay to display loader + schedule(() => { + setIsLoaded(true) + }); + }) + .catch(handleError); + + return () => { + active = false; + timeoutIds.forEach((id) => clearTimeout(id)); + }; }, []); if (error) { diff --git a/reactapp/features/Tethys/components/loader/LoadingAnimation.js b/reactapp/features/Tethys/components/loader/LoadingAnimation.js index 43613d2..8304a02 100644 --- a/reactapp/features/Tethys/components/loader/LoadingAnimation.js +++ b/reactapp/features/Tethys/components/loader/LoadingAnimation.js @@ -8,9 +8,11 @@ const LoadingAnimation = ({delay}) => { useEffect(() => { // Option to delay display of animated loader for longer resolutions - setTimeout(() => { + const timeoutId = setTimeout(() => { setShow(true); - }, delay); + }, Number(delay) || 0); + + return () => clearTimeout(timeoutId); }, [delay]); return ( @@ -45,4 +47,4 @@ LoadingAnimation.propTypes = { delay: PropTypes.number, } -export default LoadingAnimation; \ No newline at end of file +export default LoadingAnimation; diff --git a/reactapp/features/Tethys/services/api/app.js b/reactapp/features/Tethys/services/api/app.js index 1d967e5..bc17187 100644 --- a/reactapp/features/Tethys/services/api/app.js +++ b/reactapp/features/Tethys/services/api/app.js @@ -9,7 +9,7 @@ const appAPI = { { ...data }, { responseType: "arraybuffer", // key point: binary, not JSON - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json"}, } // { headers: { ...headers } } ); diff --git a/reactapp/features/Tethys/services/api/client.js b/reactapp/features/Tethys/services/api/client.js index 835b78f..88fd1ad 100644 --- a/reactapp/features/Tethys/services/api/client.js +++ b/reactapp/features/Tethys/services/api/client.js @@ -1,6 +1,6 @@ import axios from 'axios'; -import { getTethysPortalHost } from 'features/Tethys/services/utilities'; +import { getTethysPortalHost, getCookie } from 'features/Tethys/services/utilities'; const TETHYS_PORTAL_HOST = getTethysPortalHost(); @@ -9,7 +9,7 @@ axios.defaults.xsrfCookieName = "csrftoken" const apiClient = axios.create({ baseURL: `${TETHYS_PORTAL_HOST}`, - // withCredentials: true, + withCredentials: true, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' @@ -29,6 +29,11 @@ function handleError(error) { } return Promise.reject(error); } +axios.interceptors.request.use((config) => { + const csrf = getCookie('csrftoken'); + if (csrf) config.headers['X-CSRFToken'] = csrf; + return config; +}); apiClient.interceptors.response.use(handleSuccess, handleError); diff --git a/reactapp/features/Tethys/services/api/tethys.js b/reactapp/features/Tethys/services/api/tethys.js index 49613bc..7c8bb2c 100644 --- a/reactapp/features/Tethys/services/api/tethys.js +++ b/reactapp/features/Tethys/services/api/tethys.js @@ -1,30 +1,55 @@ import apiClient from "features/Tethys/services/api/client"; -function getSession() { - getCSRF() - return apiClient.get('/api/session/'); +// JWT token storage helpers +const ACCESS_TOKEN_KEY = "jwt_access"; +const REFRESH_TOKEN_KEY = "jwt_refresh"; + +export function setTokens(access, refresh) { + localStorage.setItem(ACCESS_TOKEN_KEY, access); + localStorage.setItem(REFRESH_TOKEN_KEY, refresh); +} +export function getAccessToken() { + return localStorage.getItem(ACCESS_TOKEN_KEY); +} +export function getRefreshToken() { + return localStorage.getItem(REFRESH_TOKEN_KEY); } -function getCSRF() { - return apiClient.get('/api/csrf/') - .then(response => { - return response.headers['x-csrftoken']; - }); +async function getJWTToken() { + const response = await apiClient.get("/api/token/", {}); + const access = response.access; + const refresh = response.refresh; + setTokens(access, refresh); + return { access, refresh }; +} + +async function refreshJWTToken() { + const response = await apiClient.post("/api/token/refresh/", { + refresh: getRefreshToken(), + }); + return response.data.access; } function getUserData() { - return apiClient.get('/api/whoami/'); + return apiClient.get("/api/whoami/"); } function getAppData(tethys_app_url) { return apiClient.get(`/api/apps/${tethys_app_url}/`); } +function getCSRF() { + return apiClient.get("/api/csrf/").then((response) => { + return response.headers["x-csrftoken"]; + }); +} + const tethysAPI = { - getSession, - getCSRF, + getJWTToken, + refreshJWTToken, getAppData, getUserData, + getCSRF, }; export default tethysAPI; \ No newline at end of file diff --git a/reactapp/features/Tethys/services/utilities.js b/reactapp/features/Tethys/services/utilities.js index 5b4cf5d..eeb4349 100644 --- a/reactapp/features/Tethys/services/utilities.js +++ b/reactapp/features/Tethys/services/utilities.js @@ -34,4 +34,9 @@ export function getTethysAppRoot() { let tethys_prefix_url = process.env.TETHYS_PREFIX_URL.replace(/^\/|\/$/g, ""); let fp = `/${tethys_prefix_url}/${tethys_app_root_url}`; return fp.replace(/\/{2,}/g, "/"); +} + +export function getCookie(name) { + const m = document.cookie.match(new RegExp(`(^|; )${name}=([^;]*)`)); + return m ? decodeURIComponent(m[2]) : null; } \ No newline at end of file diff --git a/reactapp/hooks/useTheme.js b/reactapp/hooks/useTheme.js deleted file mode 100644 index a8fa31d..0000000 --- a/reactapp/hooks/useTheme.js +++ /dev/null @@ -1,27 +0,0 @@ -// useTheme.js -import { useState, useEffect } from 'react'; - -const useTheme = () => { - const [theme, setTheme] = useState('light'); - - useEffect(() => { - // Detect the user's preferred color scheme - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - setTheme(mediaQuery.matches ? 'dark' : 'light'); - - const handleChange = (e) => { - setTheme(e.matches ? 'dark' : 'light'); - }; - - // Listen for changes in the preferred color scheme - mediaQuery.addEventListener('change', handleChange); - - return () => { - mediaQuery.removeEventListener('change', handleChange); - }; - }, []); - - return theme; -}; - -export default useTheme;