diff --git a/CHANGES.md b/CHANGES.md index c60509c0..9f05b778 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,10 @@ ## Changes in version 1.7.4 (in development) +### Improvements + +* Add support for multiple non-spatial dataset dimensions (e.g. time, depth). Selectors are displayed either for only one dimension or for + all dimensions at once, this can be configured in the settings.(#239) + ### Other changes * Updated dependencies. Now using diff --git a/docs/assets/images/datamanagement_dataset.png b/docs/assets/images/datamanagement_dataset.png index 22acc593..01d693d1 100644 Binary files a/docs/assets/images/datamanagement_dataset.png and b/docs/assets/images/datamanagement_dataset.png differ diff --git a/docs/assets/images/datamanagement_variables.png b/docs/assets/images/datamanagement_variables.png index e3be4845..00c7b2be 100644 Binary files a/docs/assets/images/datamanagement_variables.png and b/docs/assets/images/datamanagement_variables.png differ diff --git a/docs/assets/images/select_dimension.png b/docs/assets/images/select_dimension.png new file mode 100644 index 00000000..b4b77523 Binary files /dev/null and b/docs/assets/images/select_dimension.png differ diff --git a/docs/assets/images/select_dimension_control.png b/docs/assets/images/select_dimension_control.png new file mode 100644 index 00000000..fd18d5bf Binary files /dev/null and b/docs/assets/images/select_dimension_control.png differ diff --git a/docs/assets/images/select_dimension_mode1.png b/docs/assets/images/select_dimension_mode1.png new file mode 100644 index 00000000..59fa5fda Binary files /dev/null and b/docs/assets/images/select_dimension_mode1.png differ diff --git a/docs/assets/images/select_dimension_mode2.png b/docs/assets/images/select_dimension_mode2.png new file mode 100644 index 00000000..9fd83c62 Binary files /dev/null and b/docs/assets/images/select_dimension_mode2.png differ diff --git a/docs/concepts.md b/docs/concepts.md index 565cf86e..45a0f244 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -29,9 +29,10 @@ application instance. One of the available datasets is the viewer's A _variable_ provides n-dimensional, gridded, geospatial data. Every variable has a name, a numeric data type, and two spatial dimensions. -Usually, variables are 3-D and also have a time dimension that can be of -variable length. Each time step along the dimension determines an individual -2-D spatial image of the 3-D variable and is associated with a given timestamp. +Usually, variables are 3-D and also have one or multiple non-spatial dimensions +(e.g. time, depth, scenario) that can be of variable length. Each time step +along the dimension determines an individual 2-D spatial image of the 3-D +variable and is associated with a given timestamp. ### Places and Place Groups @@ -53,18 +54,17 @@ or CSV files. ### Selected Dataset The selected dataset determines the set of selectable [variables](#variable) -and the possible range of the currently [selected time](#selected-time). +and the possible range of the currently [selected non-spatial dimensions values](#selected-non-spatial-dimension-values). Only one dataset can be selected at the same time. -The selected dataset's time coverage limits the possible values for the -[selected time](#selected-time). If the dataset selection changes, the [selected variable](#selected-variable) -and the [selected time](#selected-time) may be adjusted if their current values -are not applicable to the newly selected dataset. In this case: +and the [selected non-spatial dimension values](#selected-time) may be adjusted +if their current values are not applicable to the newly selected dataset. +In this case: * The newly [selected variable](#selected-variable) will be the first variable of the selected dataset. -* The [selected time](#selected-time) will be the latest time stamp - of the selected dataset. +* The [selected non-spatial dimension values](#selected-time) will be the latest time + stamp or last value of the dimension. ### Selected Variable @@ -88,11 +88,16 @@ One of the available [places](#places-and-place-groups) can be selected by point at it on the map. A selected place is required, for example, to compute time-series and statistics. Only one place can be selected at the same time. -### Selected Time +### Selected Non-spatial Dimension -The _selected time_ determines the current time step for datasets with a -time dimension. For such datasets, the selected time is required, for example, -to compute statistics. +The _selected non-spatial dimension_ determines the currently selected dimension, +of which the value can be changed. + +### Selected Non-spatial Dimension Values + +The _selected non-spatial dimension values_ determines the current values for +dimensions of a datasets with that dimensions. For such datasets, +the selected dimension values are required, for example, to compute statistics. ## Layers diff --git a/docs/features.md b/docs/features.md index b09c1ec3..70abd616 100644 --- a/docs/features.md +++ b/docs/features.md @@ -159,6 +159,122 @@ A list of all the features that the viewer contains will be created here, in whi + +### Select Dimension + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Select Dimension +
Feature NameSelect Dimension
Description + A drop-down menu that lists the non-spatial dimensions of the selected + variable. +
Functionality + Select the dimension of which you want to change the values. The value + selectors then displays the values available for the selected dimension. +

+ This drop-down menu is not displayed when the selectors of all + dimensions are displayed at once. This can be enabled in the settings. + It is also not displayed when the selected variable has only one + non-spatial dimension. +
Aim + Enable users to choose which non-spatial dimension they want to + navigate through. +
+ Link to feature mentioned in + User Guide. +
+ +### Select Dimension Value + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Select Dimension Value +
Feature NameSelect Dimension Value
Description + Controls used to select the value of a non-spatial dimension. The controls + consist of a drop-down menu or a calendar (depending on the dimension) a + slider, and a player. +
Functionality + Select the dimension value that shall be displayed on the map. +

+ For time dimensions, values can be selected using the calendar, the + slider, or the player controls. For other non-spatial dimensions, values + can be selected from a drop-down menu, a slider, or a player. +

+ When the setting to show selectors for all dimensions at once is + enabled, a separate value selector is displayed for each non-spatial + dimension. +
Aim + Enable users to navigate and display specific values of one or more + non-spatial dimensions. +
+ Link to feature mentioned in + User Guide. +
+ ### Show/Hide Sidebar @@ -1368,7 +1484,7 @@ A list of all the features that the viewer contains will be created here, in whi Located in the Statistics-tab, a table displays the value at the selected point's coordinates for the selected variable. Above the table, details are shown, including the dataset, variable, - time step, and place name. + selected values of the non-spatial dimensions, and place name. @@ -2014,107 +2130,9 @@ A list of all the features that the viewer contains will be created here, in whi
-## Navigate Time Steps - -### Select Time Steps (Calender) - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Select Time Step -
Feature NameSelect Time Step (Calender)
DescriptionA button that opens a calendar window when enabled.
Functionality - The button opens a calendar window when clicked. Click on a date in the - calendar, to select and display the time step that is closest to the - chosen date.

- The time resolution (HH:MM:SS) is not supported yet, but it will be - displayed in the calendar view if the resolution is available in the - dataset. -
Aim - Easily navigate and select specific time steps within a dataset using a - visual calendar interface. -
- Link to feature mentioned in - User Guide. -
- -### Select Time Steps (Slider) - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Select Time Step -
Feature NameSelect Time Step (Slider)
Description - A time slider to navigate through the time steps of the selected - variable. The slider is labeled with the first and last time steps of - the dataset at the start and end points. -
FunctionalityMove the slider to select and display the desired time step.
AimEasily navigate and explore time steps within a dataset.
- Link to feature mentioned in - User Guide. -
+## Navigate Dimensions with a Player -### +/- 1 Time Step +### +/- 1 Step @@ -2122,41 +2140,41 @@ A list of all the features that the viewer contains will be created here, in whi - + - + @@ -2207,7 +2225,7 @@ A list of all the features that the viewer contains will be created here, in whi - +
Move to previous or next Time Step + alt="Move to previous or next step" />
Feature NameMove to +/- 1 Time StepMove to +/- 1 Step
Description Buttons, designed according to its function, allowing to navigate to - either the previous or next time step of the dataset. + either the previous or next step of a dimension.
Functionality - Click the buttons to move to the previous/next time step in the dataset - and display the corresponding data on the map. + Click the buttons to move to the previous/next step in the dimension of + in the dataset and display the corresponding data on the map.
Aim - Quick navigation option to access the previous and next time steps in - the dataset. + Quick navigation option to access the previous and next steps in + the dimension of the dataset.
Link to feature mentioned in User Guide. @@ -2173,33 +2191,33 @@ A list of all the features that the viewer contains will be created here, in whi Step to first/last Time Step + alt="Step to first/last step" />
Feature NameMove to first/last Time StepMove to first/last step
Description Buttons, designed according to its function, allowing to navigate to - either the first or last time step of the dataset. + either the first or last step of the dimension of the dataset.
Functionality - Click the buttons to move to the first/last time step in the dataset and + Click the buttons to move to the first/last step in the dataset and display the corresponding data on the map.
Aim - Quick navigation option to access the first and last time steps in the + Quick navigation option to access the first and last steps in the dataset.
Link to feature mentioned in User Guide. @@ -2240,13 +2258,13 @@ A list of all the features that the viewer contains will be created here, in whi Description A button that changes its appearance when the feature is activated, - while disabling the buttons used to navigate through time steps. + while disabling the buttons used to navigate through a dimension.
Functionality - When enabled, the feature iterates through the time steps sequentially, + When enabled, the feature iterates through the steps sequentially, displaying each step in the Viewer. The speed or interval of this iterating can be configured in the
AimEngaging way to visually explore multiple time steps.Engaging way to visually explore multiple steps of a dimension.
diff --git a/docs/user_guide/analyse.md b/docs/user_guide/analyse.md index 90fd7b3a..095f0794 100644 --- a/docs/user_guide/analyse.md +++ b/docs/user_guide/analyse.md @@ -162,16 +162,16 @@ To **exit** Compare Mode: use the button on the left --- -## Navigate through time +## Navigate through dimensions ![Player](../assets/images/analysis_player_light.png){: class="light-image" } ![Player](../assets/images/analysis_player_dark.png){: class="dark-image" } -Use the shown features of the Viewer to navigate through the time steps of a [variable](../concepts.md/#variable). Select a specific date in the calendar view, move through the dataset time step by time step, jump to the beginning or end of the dataset, move the slider on the bar to select a time step, or start the player. +Use the shown features of the Viewer to navigate through the dimension of a [variable](../concepts.md/#variable). Select a specific value, move through the dataset step by step, jump to the beginning or end of the dimension, move the slider on the bar to select a value, or start the player. ### The Player -Use the player to auto-step through the [variable](../concepts.md/#variable) of a [dataset](../concepts.md/#dataset). The interval can be adjusted in the [settings](settings.md/#player-interval). +Use the player to auto-step through the dimension of a [variable](../concepts.md/#variable). The interval can be adjusted in the [settings](settings.md/#player-interval). ![Player](../assets/videos/Player_hh.gif) diff --git a/docs/user_guide/getting_started.md b/docs/user_guide/getting_started.md index 90098b9b..b8653395 100644 --- a/docs/user_guide/getting_started.md +++ b/docs/user_guide/getting_started.md @@ -11,6 +11,27 @@ If more than one [variable](../concepts.md/#variable) is available within a [sel ![Datamanagement - Variables](../assets/images/datamanagement_variables.png) +--- + +## Select Dimensions + +Datasets can contain multiple non-spatial dimensions, such as *time*, *depth*, +or *scenario*. When such a dataset is displayed, xcube Viewer provides selectors +for navigating the available dimension values. + +The display of dimension selectors can be configured in two modes: + +**Single dimension**: Values can be selected for only one dimension at a time. + +![select_dimension_mode1.png](../assets/images/select_dimension_mode1.png) + +**All dimensions**: Selectors for all available non-spatial dimensions are shown simultaneously. + +![select_dimension_mode2.png](../assets/images/select_dimension_mode2.png) + +The preferred mode can be configured in the [settings](../user_guide/settings.md). + + --- ## Integrate (User) Base Maps and Overlays diff --git a/src/actions/controlActions.tsx b/src/actions/controlActions.tsx index b830ae78..660d757b 100644 --- a/src/actions/controlActions.tsx +++ b/src/actions/controlActions.tsx @@ -35,7 +35,8 @@ import type { LayerVisibilities, LayerGroupStates, MapInteraction, - TimeAnimationInterval, + DimensionAnimationInterval, + DimensionValues, ViewMode, VolumeRenderMode, VolumeState, @@ -446,6 +447,27 @@ export function incSelectedTime(increment: -1 | 1): IncSelectedTime { //////////////////////////////////////////////////////////////////////////////// +export const INC_SELECTED_DIMENSION = "INC_SELECTED_DIMENSION"; + +export interface IncSelectedDimension { + type: typeof INC_SELECTED_DIMENSION; + increment: -1 | 1; + selectedDimensionLabel?: string | null; +} + +export function incSelectedDimension( + increment: -1 | 1, + selectedDimensionLabel?: string | null, +): IncSelectedDimension { + return { + type: INC_SELECTED_DIMENSION, + increment, + selectedDimensionLabel, + }; +} + +//////////////////////////////////////////////////////////////////////////////// + export const SELECT_TIME_RANGE = "SELECT_TIME_RANGE"; export interface SelectTimeRange { @@ -486,17 +508,38 @@ export const UPDATE_TIME_ANIMATION = "UPDATE_TIME_ANIMATION"; export interface UpdateTimeAnimation { type: typeof UPDATE_TIME_ANIMATION; timeAnimationActive: boolean; - timeAnimationInterval: TimeAnimationInterval; + dimensionAnimationInterval: DimensionAnimationInterval; } export function updateTimeAnimation( timeAnimationActive: boolean, - timeAnimationInterval: TimeAnimationInterval, + dimensionAnimationInterval: DimensionAnimationInterval, ): UpdateTimeAnimation { return { type: UPDATE_TIME_ANIMATION, timeAnimationActive, - timeAnimationInterval, + dimensionAnimationInterval, + }; +} + +//////////////////////////////////////////////////////////////////////////////// + +export const UPDATE_DIMENSION_ANIMATION = "UPDATE_DIMENSION_ANIMATION"; + +export interface UpdateDimensionAnimation { + type: typeof UPDATE_DIMENSION_ANIMATION; + dimensionAnimationActive: boolean; + dimensionAnimationInterval: DimensionAnimationInterval; +} + +export function updateDimensionAnimation( + dimensionAnimationActive: boolean, + dimensionAnimationInterval: DimensionAnimationInterval, +): UpdateDimensionAnimation { + return { + type: UPDATE_DIMENSION_ANIMATION, + dimensionAnimationActive, + dimensionAnimationInterval, }; } @@ -851,6 +894,39 @@ export function setDatasetZLevel( //////////////////////////////////////////////////////////////////////////////// +export const SELECT_DIMENSION = "SELECT_DIMENSION"; + +export interface SelectDimension { + type: typeof SELECT_DIMENSION; + selectedDimensionLabel: string | null; +} + +export function selectDimension( + selectedDimensionLabel: string | null, +): SelectDimension { + return { type: SELECT_DIMENSION, selectedDimensionLabel }; +} + +//////////////////////////////////////////////////////////////////////////////// + +export const SELECT_DIMENSION_VALUES = "SELECT_DIMENSION_VALUES"; + +export interface SelectDimensionValues { + type: typeof SELECT_DIMENSION_VALUES; + selectedDimensionValues: DimensionValues; +} + +export function selectDimensionValues( + selectedDimensionValues: DimensionValues, +): SelectDimensionValues { + return { + type: SELECT_DIMENSION_VALUES, + selectedDimensionValues, + }; +} + +//////////////////////////////////////////////////////////////////////////////// + export type ControlAction = | SelectDataset | UpdateDatasetPlaceGroup @@ -862,9 +938,11 @@ export type ControlAction = | SetLayerVisibilities | SetLayerGroupStates | IncSelectedTime + | IncSelectedDimension | SelectTimeRange | SelectTimeSeriesUpdateMode | UpdateTimeAnimation + | UpdateDimensionAnimation | SetMapInteraction | AddActivity | RemoveActivity @@ -890,4 +968,6 @@ export type ControlAction = | UpdateVariableSplitPos | FlyTo | SetZoomLevel - | SetDatasetZLevel; + | SetDatasetZLevel + | SelectDimension + | SelectDimensionValues; diff --git a/src/actions/dataActions.tsx b/src/actions/dataActions.tsx index 703bf5ad..d164f1ec 100644 --- a/src/actions/dataActions.tsx +++ b/src/actions/dataActions.tsx @@ -42,6 +42,7 @@ import { selectedPlaceSelector, selectedServerSelector, selectedTimeChunkSizeSelector, + selectedVariableDimensionValuesSelector, selectedVariableSelector, userPlacesFormatNameSelector, userPlacesFormatOptionsCsvSelector, @@ -572,6 +573,8 @@ export function addStatistics() { const selectedTimeLabel = selectedDatasetTimeLabelSelector(getState()); const sidePanelOpen = getState().controlState.sidePanelOpen; const sidePanelId = getState().controlState.sidePanelId; + const selectedDimensionValues = + selectedVariableDimensionValuesSelector(getState()); if (!(selectedDataset && selectedVariable && selectedPlaceInfo)) { return; @@ -592,6 +595,7 @@ export function addStatistics() { selectedPlaceInfo, selectedTimeLabel, getState().userAuthState.accessToken, + selectedDimensionValues, ) .then((stats) => dispatch(_addStatistics(stats))) .catch((error: Error) => { @@ -649,6 +653,8 @@ export function addTimeSeries() { let timeChunkSize = selectedTimeChunkSizeSelector(getState()); const sidebarOpen = getState().controlState.sidePanelOpen; const sidebarPanelId = getState().controlState.sidePanelId; + const selectedDimensionValues = + selectedVariableDimensionValuesSelector(getState()); const placeGroups = placeGroupsSelector(getState()); @@ -656,7 +662,8 @@ export function addTimeSeries() { selectedDataset && selectedVariable && selectedPlaceId && - selectedDatasetTimeDim + selectedDatasetTimeDim && + selectedDimensionValues ) { if (sidebarPanelId !== "timeSeries") { dispatch(setSidePanelId("timeSeries")); @@ -677,6 +684,7 @@ export function addTimeSeries() { const startDateLabel = startTimeIndex >= 0 ? timeLabels[startTimeIndex] : null; const endDateLabel = timeLabels[endTimeIndex]; + return api.getTimeSeriesForGeometry( apiServer.url, selectedDataset, @@ -688,6 +696,7 @@ export function addTimeSeries() { useMedian, includeStdev, getState().userAuthState.accessToken, + selectedDimensionValues, ); }; diff --git a/src/api/getStatistics.ts b/src/api/getStatistics.ts index 9a1d9746..22a4b402 100644 --- a/src/api/getStatistics.ts +++ b/src/api/getStatistics.ts @@ -19,6 +19,7 @@ import { QueryComponent, } from "./callApi"; import { encodeDatasetId, encodeVariableName } from "@/model/encode"; +import { DimensionValues } from "@/states/controlState"; interface StatisticsResult { result: Statistics; @@ -31,9 +32,19 @@ export function getStatistics( placeInfo: PlaceInfo, timeLabel: string | null, accessToken: string | null, + dimensionValues: DimensionValues, ): Promise { - const query: QueryComponent[] = - timeLabel !== null ? [["time", timeLabel]] : []; + const query: QueryComponent[] = []; + if (timeLabel) { + query.push(["time", timeLabel]); + } + + Object.entries(dimensionValues).forEach(([name, value]) => { + if (value != null) { + query.push([name, String(value)]); + } + }); + const url = makeRequestUrl( `${apiServerUrl}/statistics/${encodeDatasetId(dataset)}/${encodeVariableName(variable)}`, query, @@ -50,6 +61,7 @@ export function getStatistics( variable, placeInfo, time: timeLabel, + dimensionValues: dimensionValues, }; return callJsonApi(url, init, (r: StatisticsResult) => ({ diff --git a/src/api/getTimeSeries.ts b/src/api/getTimeSeries.ts index ad0f096f..1c50f4d8 100644 --- a/src/api/getTimeSeries.ts +++ b/src/api/getTimeSeries.ts @@ -16,6 +16,7 @@ import { QueryComponent, } from "./callApi"; import { encodeDatasetId, encodeVariableName } from "@/model/encode"; +import { DimensionValues } from "@/states/controlState"; type RawTimeSeriesPoint = Omit & { time: string }; @@ -34,6 +35,7 @@ export function getTimeSeriesForGeometry( useMedian: boolean, includeStdev: boolean, accessToken: string | null, + dimensionValues: DimensionValues, ): Promise { let valueDataKey: keyof TimeSeriesPoint; let errorDataKey: keyof TimeSeriesPoint | null = null; @@ -55,6 +57,11 @@ export function getTimeSeriesForGeometry( if (endDate) { query.push(["endDate", endDate]); } + Object.entries(dimensionValues).forEach(([name, value]) => { + if (value != null) { + query.push([name, String(value)]); + } + }); const url = makeRequestUrl( `${apiServerUrl}/timeseries/${encodeDatasetId(dataset)}/${encodeVariableName(variable)}`, query, @@ -85,6 +92,7 @@ export function getTimeSeriesForGeometry( geometry, valueDataKey, errorDataKey, + dimensionValues, }; return { source, data: points }; }; diff --git a/src/components/ControlBar.tsx b/src/components/ControlBar.tsx index 4547e5ff..eba3b7de 100644 --- a/src/components/ControlBar.tsx +++ b/src/components/ControlBar.tsx @@ -10,10 +10,10 @@ import { styled, Theme } from "@mui/system"; const ControlBarForm = styled("form")(({ theme }: { theme: Theme }) => ({ display: "flex", flexWrap: "wrap", - paddingTop: theme.spacing(1), + paddingTop: theme.spacing(0.75), paddingLeft: theme.spacing(0.5), paddingRight: theme.spacing(0), - paddingBottom: theme.spacing(0.25), + paddingBottom: theme.spacing(0.75), flexGrow: 0, })); diff --git a/src/components/ControlBarItem.tsx b/src/components/ControlBarItem.tsx index 643786ea..2bd697da 100644 --- a/src/components/ControlBarItem.tsx +++ b/src/components/ControlBarItem.tsx @@ -14,6 +14,7 @@ import { WithLocale } from "@/util/lang"; const StyledForm = styled(FormControl)(({ theme }: { theme: Theme }) => ({ marginRight: theme.spacing(1), marginLeft: theme.spacing(2), + minWidth: 50, })); interface ControlBarItemProps extends WithLocale { diff --git a/src/components/DimensionSelect.tsx b/src/components/DimensionSelect.tsx new file mode 100644 index 00000000..edec603a --- /dev/null +++ b/src/components/DimensionSelect.tsx @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import Input from "@mui/material/Input"; +import InputLabel from "@mui/material/InputLabel"; +import ListItemText from "@mui/material/ListItemText"; +import MenuItem from "@mui/material/MenuItem"; +import Select, { SelectChangeEvent } from "@mui/material/Select"; + +import { WithLocale } from "@/util/lang"; + +import ControlBarItem from "./ControlBarItem"; +import { Variable } from "@/model/variable"; +import { useEffect } from "react"; +import i18n from "@/i18n"; + +interface DimensionsSelectProps extends WithLocale { + selectedVariable: Variable | null; + selectedDimensionLabel: string | null; + showAllDimensions: boolean; + selectDimension: (selectedDimensionLabel: string | null) => void; +} + +export default function DimensionSelect({ + selectedVariable, + selectedDimensionLabel, + showAllDimensions, + selectDimension, +}: DimensionsSelectProps) { + useEffect(() => { + const nonSpatialDims = + selectedVariable?.dims?.filter( + (dim) => + !["lat", "lon", "latitude", "longitude", "x", "y"].includes(dim), + ) ?? []; + + const hasSelectedDimension = + selectedDimensionLabel != null && + nonSpatialDims.includes(selectedDimensionLabel); + + if (nonSpatialDims.length && !hasSelectedDimension) { + selectDimension(nonSpatialDims[0]); + } + }, [selectedVariable, selectedDimensionLabel, selectDimension]); + + if (!selectedVariable || !selectedDimensionLabel || showAllDimensions) + return null; + + const handleDimensionChange = (event: SelectChangeEvent) => { + selectDimension(String(event.target.value)); + }; + + //TODO: add to lang.json + const dimensionSelectLabel = ( + + {i18n.get("Dimension")} + + ); + + const nonSpatialDims = + selectedVariable?.dims?.filter( + (dim) => !["lat", "lon", "latitude", "longitude", "x", "y"].includes(dim), + ) ?? []; + + if (nonSpatialDims.length <= 1) { + return null; + } + + //TODO: ducplication + const hasSelectedDimension = selectedVariable?.dims?.includes( + selectedDimensionLabel, + ); + + const dimensionSelectValue = hasSelectedDimension + ? selectedDimensionLabel + : ""; + + const dimensionSelect = ( + + ); + + return ( + + ); +} diff --git a/src/components/DimensionValueControl.tsx b/src/components/DimensionValueControl.tsx new file mode 100644 index 00000000..97cd504a --- /dev/null +++ b/src/components/DimensionValueControl.tsx @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import { WithLocale } from "@/util/lang"; + +import { Variable } from "@/model/variable"; +import TimePlayer from "@/connected/TimePlayer"; +import TimeSelect from "@/connected/TimeSelect"; +import TimeSlider from "@/connected/TimeSlider"; +import DimensionValuePlayer from "@/connected/DimensionValuePlayer"; +import DimensionValueSelect from "@/connected/DimensionValueSelect"; +import DimensionValueSlider from "@/connected/DimensionValueSlider"; +import { isSpatialDim } from "@/model/dataset"; +import Box from "@mui/material/Box"; +import Divider from "@mui/material/Divider"; + +interface DimensionsValueControlProps extends WithLocale { + selectedVariable: Variable | null; + selectedDimensionLabel: string | null; + showAllDimensions: boolean; +} + +export default function DimensionValueControl({ + selectedVariable, + selectedDimensionLabel, + showAllDimensions, +}: DimensionsValueControlProps) { + if (!selectedVariable) return null; + + const renderDimensionControls = (dimensionLabel: string) => { + const isTimeDimension = dimensionLabel === "time"; + return isTimeDimension ? ( + <> + + + + + ) : ( + <> + + + + + ); + }; + + if (showAllDimensions) { + const dimensionLabels = + selectedVariable.dims?.filter((dimension) => !isSpatialDim(dimension)) ?? + []; + + return ( + + {dimensionLabels.map((dimensionLabel, index) => ( + + {renderDimensionControls(dimensionLabel)} + + {index < dimensionLabels.length - 1 && ( + + )} + + ))} + + ); + } + + if (!selectedDimensionLabel) return null; + + return <>{renderDimensionControls(selectedDimensionLabel)}; +} diff --git a/src/components/DimensionValuePlayer.tsx b/src/components/DimensionValuePlayer.tsx new file mode 100644 index 00000000..6b06216b --- /dev/null +++ b/src/components/DimensionValuePlayer.tsx @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import { useEffect, useRef } from "react"; +import Box from "@mui/material/Box"; +import FormControl from "@mui/material/FormControl"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import FirstPageIcon from "@mui/icons-material/FirstPage"; +import LastPageIcon from "@mui/icons-material/LastPage"; +import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline"; +import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"; + +import i18n from "@/i18n"; +import { WithLocale } from "@/util/lang"; +import { + DimensionAnimationInterval, + DimensionValues, +} from "@/states/controlState"; +import { makeStyles } from "@/util/styles"; +import { Variable } from "@/model/variable"; +import { Dimension } from "@/model/dataset"; + +// noinspection JSUnusedLocalSymbols +const styles = makeStyles({ + formControl: { + marginLeft: 1, + marginRight: 1, + marginTop: 2, + }, + iconButton: { + padding: "2px", + }, +}); + +interface DimensionValuePlayerProps extends WithLocale { + selectedVariable: Variable | null; + selectedDimensionLabel: string | null; + selectedDimension: Dimension | null; + selectedDimensionValue: number | string | null; + selectDimensionValues: (selectedValues: DimensionValues) => void; + dimensionAnimationActive: boolean; + dimensionAnimationInterval: DimensionAnimationInterval; + incSelectedDimension: ( + increment: -1 | 1, + selectedDimensionLabel?: string | null, + ) => void; + updateDimensionAnimation: ( + active: boolean, + interval: DimensionAnimationInterval, + ) => void; +} + +export default function DimensionValuePlayer({ + selectedVariable, + selectedDimensionLabel, + selectedDimension, + selectedDimensionValue, + selectDimensionValues, + incSelectedDimension, + dimensionAnimationActive, + dimensionAnimationInterval, + updateDimensionAnimation, +}: DimensionValuePlayerProps) { + const intervalId = useRef(null); + + useEffect(() => { + playOrNot(); + return uninstallTimer; + }); + + const playOrNot = () => { + if (dimensionAnimationActive) { + installTimer(); + } else { + uninstallTimer(); + } + }; + + const installTimer = () => { + uninstallTimer(); + intervalId.current = window.setInterval( + handlePlayEvent, + dimensionAnimationInterval, + ); + }; + + const uninstallTimer = () => { + if (intervalId.current !== null) { + window.clearInterval(intervalId.current!); + intervalId.current = null; + } + }; + + // only show DepthSelect if selectedVariables has depth dim + // and selectedDimensionValue + if ( + !selectedDimension || + !selectedDimensionLabel || + !selectedVariable?.dims?.includes(selectedDimension.name) || + selectedDimensionValue === null || + selectedDimensionValue === undefined + ) + return null; + + const selectedCoordinates = selectedDimension.coordinates; + + const handlePlayEvent = () => { + incSelectedDimension(1, selectedDimensionLabel); + }; + + const handlePlayButtonClick = () => { + updateDimensionAnimation( + !dimensionAnimationActive, + dimensionAnimationInterval, + ); + }; + + const handleNextStepButtonClick = () => { + incSelectedDimension(1, selectedDimensionLabel); + }; + + const handlePrevStepButtonClick = () => { + incSelectedDimension(-1, selectedDimensionLabel); + }; + + const handleFirstStepButtonClick = () => { + selectDimensionValues({ + [selectedDimensionLabel]: selectedCoordinates + ? selectedCoordinates[0] + : null, + }); + }; + + const handleLastStepButtonClick = () => { + selectDimensionValues({ + [selectedDimensionLabel]: selectedCoordinates + ? selectedCoordinates[selectedCoordinates.length - 1] + : null, + }); + }; + + const isValid = typeof selectedDimensionValue === "number"; + + const playIcon = dimensionAnimationActive ? ( + + ) : ( + + ); + + const playButton = ( + + + {playIcon} + + + ); + + const firstStepButtonClick = ( + + + + + + ); + + const prevStepButtonClick = ( + + + + + + ); + const nextStepButtonClick = ( + + + + + + ); + const lastStepButtonClick = ( + + + + + + ); + + return ( + + + {firstStepButtonClick} + {prevStepButtonClick} + {playButton} + {nextStepButtonClick} + {lastStepButtonClick} + + + ); +} diff --git a/src/components/DimensionValueSelect.tsx b/src/components/DimensionValueSelect.tsx new file mode 100644 index 00000000..6a10c850 --- /dev/null +++ b/src/components/DimensionValueSelect.tsx @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import Input from "@mui/material/Input"; +import InputLabel from "@mui/material/InputLabel"; +import ListItemText from "@mui/material/ListItemText"; +import MenuItem from "@mui/material/MenuItem"; +import Select, { SelectChangeEvent } from "@mui/material/Select"; + +import { WithLocale } from "@/util/lang"; + +import ControlBarItem from "./ControlBarItem"; +import { Dimension } from "@/model/dataset"; +import { Variable } from "@/model/variable"; +import { useEffect } from "react"; +import { DimensionValues } from "@/states/controlState"; + +interface DimensionValueSelectProps extends WithLocale { + selectedVariable: Variable | null; + selectedDimensionLabel: string | null; + selectedDimension: Dimension | null; + selectedDimensionValue: number | string | null; + selectDimensionValues: (selectedValues: DimensionValues) => void; +} + +export default function DimensionValueSelect({ + selectedVariable, + selectedDimensionLabel, + selectedDimension, + selectedDimensionValue, + selectDimensionValues, +}: DimensionValueSelectProps) { + //TODO: simplify is possible + useEffect(() => { + const hasSelectedValue = + selectedDimensionValue !== null && + selectedDimensionValue !== undefined && + selectedDimension?.coordinates?.includes(Number(selectedDimensionValue)); + + if ( + selectedDimensionLabel && + !hasSelectedValue && + selectedDimension?.coordinates?.length + ) { + selectDimensionValues({ + [selectedDimensionLabel]: selectedDimension.coordinates[0], + }); + } + }, [ + selectedDimensionValue, + selectedDimension, + selectDimensionValues, + selectedDimensionLabel, + ]); + + // only show component if selectedVariables has dimension + if ( + !selectedDimensionLabel || + !selectedDimension || + selectedDimensionValue === null || + selectedDimensionValue === undefined || + !selectedVariable?.dims?.includes(selectedDimension.name) + ) + return null; + + const handleValueChange = (event: SelectChangeEvent) => { + selectDimensionValues({ [selectedDimensionLabel]: event.target.value }); + }; + + const valueSelectLabel = ( + + {selectedDimensionLabel} + + ); + + const valueSelect = ( + + ); + + return ( + + ); +} diff --git a/src/components/DimensionValueSlider.tsx b/src/components/DimensionValueSlider.tsx new file mode 100644 index 00000000..53ebdeef --- /dev/null +++ b/src/components/DimensionValueSlider.tsx @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import * as React from "react"; +import { useEffect, useState } from "react"; +import Box from "@mui/material/Box"; +import Slider from "@mui/material/Slider"; +import { Mark } from "@mui/base/useSlider"; +import Tooltip from "@mui/material/Tooltip"; + +import i18n from "@/i18n"; +import { makeStyles } from "@/util/styles"; +import { Dimension } from "@/model/dataset"; +import { Variable } from "@/model/variable"; +import { DimensionValues } from "@/states/controlState"; + +const HOR_MARGIN = 5; + +// noinspection JSUnusedLocalSymbols +const styles = makeStyles({ + box: { + marginLeft: HOR_MARGIN, + marginRight: HOR_MARGIN, + minWidth: "10rem", + height: "3rem", + }, +}); + +interface DimensionValueSliderProps { + selectedVariable: Variable | null; + selectedDimensionLabel: string | null; + selectedDimension: Dimension | null; + selectedDimensionValue: number | string | null; + selectDimensionValues: (selectedValues: DimensionValues) => void; +} + +export default function DimensionValueSliderProps({ + selectedVariable, + selectedDimensionLabel, + selectedDimension, + selectedDimensionValue, + selectDimensionValues, +}: DimensionValueSliderProps) { + const [selectedDimensionValue_, setSelectedDimensionValue_] = useState( + selectedDimensionValue, + ); + + useEffect(() => { + setSelectedDimensionValue_( + selectedDimensionValue ?? + (selectedDimension?.coordinates ? selectedDimension.coordinates[0] : 0), + ); + }, [selectedDimensionValue, selectedDimension]); + + // only show DepthSelect if selectedVariables has depth dim + // and selectedDimensionValue + if ( + !selectedDimension || + !selectedVariable?.dims?.includes(selectedDimension.name) || + selectedDimensionValue === null || + selectedDimensionValue === undefined + ) + return null; + + let selectedCoordinates = selectedDimension.coordinates; + + const handleChange = (_event: Event, value: number | number[]) => { + if (typeof value === "number") { + setSelectedDimensionValue_(value); + } + }; + + const handleChangeCommitted = ( + _event: React.SyntheticEvent | Event, + value: number | number[], + ) => { + if ( + selectedDimensionLabel !== null && + selectDimensionValues && + typeof value === "number" + ) { + selectDimensionValues({ [selectedDimensionLabel]: value as number }); + } + }; + + function valueLabelFormat(value: number) { + return String(value.toFixed(2)); + } + + const selectedDepthRangeValid = Array.isArray(selectedCoordinates); + if (!selectedDepthRangeValid) { + selectedCoordinates = [0, 1]; + } + + const min = selectedCoordinates[0]; + const max = selectedCoordinates[selectedCoordinates.length - 1]; + // only labels for the min and max values + const marks: Mark[] = selectedCoordinates.map((value, index) => ({ + value, + label: + index === 0 || index === selectedCoordinates.length - 1 + ? value.toFixed(2) + : undefined, + })); + + return ( + + + + + + ); +} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index c0fbb4ad..9da0979d 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -19,11 +19,11 @@ import { Theme, useTheme } from "@mui/material"; import i18n from "@/i18n"; import { ApiServerConfig, ApiServerInfo } from "@/model/apiServer"; import { - TIME_ANIMATION_INTERVALS, + DIMENSION_ANIMATION_INTERVALS, ControlState, LocateMode, TimeSeriesChartType, - TimeAnimationInterval, + DimensionAnimationInterval, THEME_LABELS, ThemeMode, ExportResolution, @@ -131,9 +131,9 @@ const SettingsDialog: React.FC = ({ event: React.ChangeEvent, ) { updateSettings({ - timeAnimationInterval: parseInt( + dimensionAnimationInterval: parseInt( event.target.value, - ) as TimeAnimationInterval, + ) as DimensionAnimationInterval, }); } @@ -241,16 +241,26 @@ const SettingsDialog: React.FC = ({ value={i18n.languages[settings.locale]} onClick={handleLanguageMenuOpen} /> + + + - {TIME_ANIMATION_INTERVALS.map((value, i) => ( + {DIMENSION_ANIMATION_INTERVALS.map((value, i) => ( {value + " ms"} diff --git a/src/components/StatisticsPanel/StatisticsDataRow.tsx b/src/components/StatisticsPanel/StatisticsDataRow.tsx index f7d8025d..92988f47 100644 --- a/src/components/StatisticsPanel/StatisticsDataRow.tsx +++ b/src/components/StatisticsPanel/StatisticsDataRow.tsx @@ -58,7 +58,8 @@ export default function StatisticsDataRow({ const containerRef = useRef(null); const [brush, setBrush] = useState(false); const [details, setDetails] = useState(false); - const { dataset, variable, time, placeInfo } = statisticsRecord.source; + const { dataset, variable, time, placeInfo, dimensionValues } = + statisticsRecord.source; const hasHistogram = isAreaStatistics(statisticsRecord.statistics); const handleToggleDetails = () => { setDetails(!details); @@ -69,12 +70,14 @@ export default function StatisticsDataRow({ const handleRemoveStatistics = () => { removeStatistics(rowIndex); }; + return ( diff --git a/src/components/StatisticsPanel/StatisticsFirstRow.tsx b/src/components/StatisticsPanel/StatisticsFirstRow.tsx index f8febbe9..f9d9dcae 100644 --- a/src/components/StatisticsPanel/StatisticsFirstRow.tsx +++ b/src/components/StatisticsPanel/StatisticsFirstRow.tsx @@ -4,8 +4,9 @@ * https://opensource.org/licenses/MIT. */ -import IconButton from "@mui/material/IconButton"; import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; import { makeStyles } from "@/util/styles"; import { WithLocale } from "@/util/lang"; @@ -13,7 +14,7 @@ import { Dataset } from "@/model/dataset"; import { Variable } from "@/model/variable"; import { PlaceInfo } from "@/model/place"; import StatisticsRow from "./StatisticsRow"; -import CircularProgress from "@mui/material/CircularProgress"; +import { DimensionValues } from "@/states/controlState"; const styles = makeStyles({ progress: { @@ -25,6 +26,7 @@ interface StatisticsFirstRowProps extends WithLocale { selectedDataset: Dataset | null; selectedVariable: Variable | null; selectedTime: string | null; + selectedDimensionValues: DimensionValues; selectedPlaceInfo: PlaceInfo | null; canAddStatistics: boolean; addStatistics: () => void; @@ -35,6 +37,7 @@ export default function StatisticsFirstRow({ selectedDataset, selectedVariable, selectedTime, + selectedDimensionValues, selectedPlaceInfo, canAddStatistics, addStatistics, @@ -45,6 +48,7 @@ export default function StatisticsFirstRow({ dataset={selectedDataset} variable={selectedVariable} time={selectedTime} + dimensionValues={selectedDimensionValues} placeInfo={selectedPlaceInfo} actions={ statisticsLoading ? ( diff --git a/src/components/StatisticsPanel/StatisticsPanel.tsx b/src/components/StatisticsPanel/StatisticsPanel.tsx index 95e00028..59d77db4 100644 --- a/src/components/StatisticsPanel/StatisticsPanel.tsx +++ b/src/components/StatisticsPanel/StatisticsPanel.tsx @@ -6,7 +6,7 @@ import Box from "@mui/material/Box"; -import { ExportResolution } from "@/states/controlState"; +import { DimensionValues, ExportResolution } from "@/states/controlState"; import { MessageType } from "@/states/messageLogState"; import { StatisticsRecord } from "@/model/statistics"; import { Dataset } from "@/model/dataset"; @@ -29,6 +29,7 @@ interface StatisticsPanelProps { selectedDataset: Dataset | null; selectedVariable: Variable | null; selectedTime: string | null; + selectedDimensionValues: DimensionValues; selectedPlaceInfo: PlaceInfo | null; statisticsLoading: boolean; statisticsRecords: StatisticsRecord[]; @@ -43,6 +44,7 @@ export default function StatisticsPanel({ selectedDataset, selectedVariable, selectedTime, + selectedDimensionValues, selectedPlaceInfo, statisticsLoading, statisticsRecords, @@ -58,6 +60,7 @@ export default function StatisticsPanel({ selectedDataset={selectedDataset} selectedVariable={selectedVariable} selectedTime={selectedTime} + selectedDimensionValues={selectedDimensionValues} selectedPlaceInfo={selectedPlaceInfo} canAddStatistics={canAddStatistics} addStatistics={addStatistics} diff --git a/src/components/StatisticsPanel/StatisticsRow.tsx b/src/components/StatisticsPanel/StatisticsRow.tsx index 44c3969a..80f7eee2 100644 --- a/src/components/StatisticsPanel/StatisticsRow.tsx +++ b/src/components/StatisticsPanel/StatisticsRow.tsx @@ -4,17 +4,18 @@ * https://opensource.org/licenses/MIT. */ +import i18n from "@/i18n"; import React, { RefObject } from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { makeStyles } from "@/util/styles"; import { WithLocale } from "@/util/lang"; +import { makeStyles } from "@/util/styles"; +import { isoDateTimeStringToLabel } from "@/util/time"; import { Dataset } from "@/model/dataset"; import { Variable } from "@/model/variable"; import { PlaceInfo } from "@/model/place"; -import i18n from "@/i18n"; -import { isoDateTimeStringToLabel } from "@/util/time"; +import { DimensionValues } from "@/states/controlState"; const styles = makeStyles({ container: { @@ -40,6 +41,7 @@ interface StatisticsRowProps extends WithLocale { dataset: Dataset | null; variable: Variable | null; time: string | null; + dimensionValues: DimensionValues; placeInfo: PlaceInfo | null; actions: React.ReactNode; body?: React.ReactNode; @@ -54,6 +56,7 @@ export default function StatisticsRow({ dataset, variable, time, + dimensionValues, placeInfo, actions, body, @@ -73,6 +76,13 @@ export default function StatisticsRow({ ) : isTimeDimensionAvailable ? ( ) : null; + + const dimensionLabel = dimensionValues + ? Object.entries(dimensionValues) + .map(([dimLabel, value]) => `${dimLabel}: ${value}`) + .join(", ") + : ""; + const placeLabel = placeInfo ? placeInfo.label : ; return ( @@ -80,7 +90,15 @@ export default function StatisticsRow({ {datasetLabel} / {variableLabel} - {timeLabel && `, ${timeLabel}`}, {placeLabel} + {timeLabel && ( + <> + {", "} + {timeLabel} + + )} + {dimensionLabel && `, ${dimensionLabel} `} + {", "} + {placeLabel} {actions} diff --git a/src/components/TimePlayer.tsx b/src/components/TimePlayer.tsx index d4fcfcf4..708fda3c 100644 --- a/src/components/TimePlayer.tsx +++ b/src/components/TimePlayer.tsx @@ -19,7 +19,7 @@ import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"; import i18n from "@/i18n"; import { WithLocale } from "@/util/lang"; import { Time, TimeRange } from "@/model/timeSeries"; -import { TimeAnimationInterval } from "@/states/controlState"; +import { DimensionAnimationInterval } from "@/states/controlState"; import { makeStyles } from "@/util/styles"; // noinspection JSUnusedLocalSymbols @@ -40,16 +40,16 @@ interface TimePlayerProps extends WithLocale { incSelectedTime: (increment: -1 | 1) => void; selectedTimeRange: TimeRange | null; timeAnimationActive: boolean; - timeAnimationInterval: TimeAnimationInterval; + dimensionAnimationInterval: DimensionAnimationInterval; updateTimeAnimation: ( active: boolean, - interval: TimeAnimationInterval, + interval: DimensionAnimationInterval, ) => void; } export default function TimePlayer({ timeAnimationActive, - timeAnimationInterval, + dimensionAnimationInterval, updateTimeAnimation, selectedTime, selectedTimeRange, @@ -68,7 +68,7 @@ export default function TimePlayer({ }; const handlePlayButtonClick = () => { - updateTimeAnimation(!timeAnimationActive, timeAnimationInterval); + updateTimeAnimation(!timeAnimationActive, dimensionAnimationInterval); }; const handleNextTimeStepButtonClick = () => { @@ -99,7 +99,7 @@ export default function TimePlayer({ uninstallTimer(); intervalId.current = window.setInterval( handlePlayEvent, - timeAnimationInterval, + dimensionAnimationInterval, ); }; diff --git a/src/components/TimeSelect.tsx b/src/components/TimeSelect.tsx index b94aef24..2db2da44 100644 --- a/src/components/TimeSelect.tsx +++ b/src/components/TimeSelect.tsx @@ -24,7 +24,7 @@ import ControlBarItem from "./ControlBarItem"; const styles = (theme: Theme) => createStyles({ dateTimePicker: { - marginTop: theme.spacing(2), + marginTop: theme.spacing(2.25), }, }); diff --git a/src/components/TimeSeriesPanel/TimeSeriesLine.tsx b/src/components/TimeSeriesPanel/TimeSeriesLine.tsx index 09f1650a..76e36873 100644 --- a/src/components/TimeSeriesPanel/TimeSeriesLine.tsx +++ b/src/components/TimeSeriesPanel/TimeSeriesLine.tsx @@ -66,6 +66,13 @@ export default function TimeSeriesLine({ let lineName = `${source.datasetTitle}:${source.variableName}`; let lineColor = "red"; + + const dimensionLabel = source.dimensionValues + ? Object.entries(source.dimensionValues) + .map(([dimLabel, value]) => `${dimLabel}: ${value}`) + .join(", ") + : ""; + if (source.placeId === null) { // Time series is from imported CSV or GeoJSON. // Then source.datasetId is the place group name. @@ -87,9 +94,9 @@ export default function TimeSeriesLine({ if (place.geometry.type === "Point") { const lon = place.geometry.coordinates[0]; const lat = place.geometry.coordinates[1]; - lineName += ` (${label}: ${lat.toFixed(5)},${lon.toFixed(5)})`; + lineName += ` (${label}: ${lat.toFixed(5)},${lon.toFixed(5)} ${dimensionLabel})`; } else { - lineName += ` (${label})`; + lineName += ` (${label} ${dimensionLabel})`; } lineColor = color; } diff --git a/src/connected/ControlBar.tsx b/src/connected/ControlBar.tsx index e1c956ca..28b921f3 100644 --- a/src/connected/ControlBar.tsx +++ b/src/connected/ControlBar.tsx @@ -12,12 +12,11 @@ import ControlBarComponent from "@/components/ControlBar"; import { WithLocale } from "@/util/lang"; import DatasetSelect from "./DatasetSelect"; import VariableSelect from "./VariableSelect"; +import DimensionSelect from "./DimensionSelect"; +import DimensionValueControl from "./DimensionValueControl"; import PlaceGroupsSelect from "./PlaceGroupsSelect"; import PlaceSelect from "./PlaceSelect"; import MapInteractionsBar from "./MapInteractionsBar"; -import TimeSelect from "./TimeSelect"; -import TimeSlider from "./TimeSlider"; -import TimePlayer from "./TimePlayer"; import ControlBarActions from "./ControlBarActions"; import Divider from "@mui/material/Divider"; @@ -44,13 +43,12 @@ const _ControlBar: React.FC = ({ show }) => { + + + - - - - ); diff --git a/src/connected/DimensionSelect.tsx b/src/connected/DimensionSelect.tsx new file mode 100644 index 00000000..f73a977f --- /dev/null +++ b/src/connected/DimensionSelect.tsx @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import { connect } from "react-redux"; + +import _DimensionSelect from "@/components/DimensionSelect"; +import { AppState } from "@/states/appState"; +import { selectDimension } from "@/actions/controlActions"; +import { + selectedDimensionLabelSelector, + selectedVariableSelector, + showAllDimensionsSelector, +} from "@/selectors/controlSelectors"; + +const mapStateToProps = (state: AppState) => { + return { + locale: state.controlState.locale, + selectedVariable: selectedVariableSelector(state), + selectedDimensionLabel: selectedDimensionLabelSelector(state), + showAllDimensions: showAllDimensionsSelector(state), + }; +}; + +const mapDispatchToProps = { + selectDimension, +}; + +const DimensionSelect = connect( + mapStateToProps, + mapDispatchToProps, +)(_DimensionSelect); +export default DimensionSelect; diff --git a/src/connected/DimensionValueControl.tsx b/src/connected/DimensionValueControl.tsx new file mode 100644 index 00000000..379268de --- /dev/null +++ b/src/connected/DimensionValueControl.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import { connect } from "react-redux"; + +import _DimensionValueControl from "@/components/DimensionValueControl"; +import { AppState } from "@/states/appState"; +import { + selectedDimensionLabelSelector, + selectedVariableSelector, + showAllDimensionsSelector, +} from "@/selectors/controlSelectors"; + +const mapStateToProps = (state: AppState) => { + return { + locale: state.controlState.locale, + selectedVariable: selectedVariableSelector(state), + selectedDimensionLabel: selectedDimensionLabelSelector(state), + showAllDimensions: showAllDimensionsSelector(state), + }; +}; + +const mapDispatchToProps = {}; + +const DimensionValueControl = connect( + mapStateToProps, + mapDispatchToProps, +)(_DimensionValueControl); +export default DimensionValueControl; diff --git a/src/connected/DimensionValuePlayer.tsx b/src/connected/DimensionValuePlayer.tsx new file mode 100644 index 00000000..90e8d174 --- /dev/null +++ b/src/connected/DimensionValuePlayer.tsx @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import { connect } from "react-redux"; + +import _DimensionValuePlayer from "@/components/DimensionValuePlayer"; +import { AppState } from "@/states/appState"; +import { + incSelectedDimension, + selectDimensionValues, + updateDimensionAnimation, +} from "@/actions/controlActions"; +import { + effectiveSelectedDimensionLabelSelector, + selectedDatasetDimensionForLabelSelector, + selectedDatasetDimensionValueForLabelSelector, + selectedVariableSelector, +} from "@/selectors/controlSelectors"; + +interface OwnProps { + dimensionLabel?: string; +} + +const mapStateToProps = (state: AppState, ownProps: OwnProps) => { + const selectedDimensionLabel = effectiveSelectedDimensionLabelSelector( + state, + ownProps.dimensionLabel, + ); + return { + locale: state.controlState.locale, + selectedVariable: selectedVariableSelector(state), + selectedDimensionLabel, + selectedDimension: selectedDatasetDimensionForLabelSelector( + state, + ownProps.dimensionLabel, + ), + selectedDimensionValue: selectedDatasetDimensionValueForLabelSelector( + state, + ownProps.dimensionLabel, + ), + dimensionAnimationActive: state.controlState.dimensionAnimationActive, + dimensionAnimationInterval: state.controlState.dimensionAnimationInterval, + }; +}; + +const mapDispatchToProps = { + selectDimensionValues, + incSelectedDimension, + updateDimensionAnimation, +}; + +const DimensionValuePlayer = connect( + mapStateToProps, + mapDispatchToProps, +)(_DimensionValuePlayer); +export default DimensionValuePlayer; diff --git a/src/connected/DimensionValueSelect.tsx b/src/connected/DimensionValueSelect.tsx new file mode 100644 index 00000000..f91af364 --- /dev/null +++ b/src/connected/DimensionValueSelect.tsx @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import { connect } from "react-redux"; + +import _DimensionValueSelect from "@/components/DimensionValueSelect"; +import { AppState } from "@/states/appState"; +import { selectDimensionValues } from "@/actions/controlActions"; +import { + effectiveSelectedDimensionLabelSelector, + selectedDatasetDimensionForLabelSelector, + selectedDatasetDimensionValueForLabelSelector, + selectedVariableSelector, +} from "@/selectors/controlSelectors"; + +interface OwnProps { + dimensionLabel?: string; +} + +const mapStateToProps = (state: AppState, ownProps: OwnProps) => { + const selectedDimensionLabel = effectiveSelectedDimensionLabelSelector( + state, + ownProps.dimensionLabel, + ); + return { + locale: state.controlState.locale, + selectedVariable: selectedVariableSelector(state), + selectedDimensionLabel, + selectedDimension: selectedDatasetDimensionForLabelSelector( + state, + ownProps.dimensionLabel, + ), + selectedDimensionValue: selectedDatasetDimensionValueForLabelSelector( + state, + ownProps.dimensionLabel, + ), + }; +}; + +const mapDispatchToProps = { + selectDimensionValues, +}; + +const DimensionValueSelect = connect( + mapStateToProps, + mapDispatchToProps, +)(_DimensionValueSelect); +export default DimensionValueSelect; diff --git a/src/connected/DimensionValueSlider.tsx b/src/connected/DimensionValueSlider.tsx new file mode 100644 index 00000000..506a3bbc --- /dev/null +++ b/src/connected/DimensionValueSlider.tsx @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2019-2026 by xcube team and contributors + * Permissions are hereby granted under the terms of the MIT License: + * https://opensource.org/licenses/MIT. + */ + +import { connect } from "react-redux"; + +import _DimensionValueSlider from "@/components/DimensionValueSlider"; +import { AppState } from "@/states/appState"; +import { selectDimensionValues } from "@/actions/controlActions"; +import { + effectiveSelectedDimensionLabelSelector, + selectedDatasetDimensionForLabelSelector, + selectedDatasetDimensionValueForLabelSelector, + selectedVariableSelector, +} from "@/selectors/controlSelectors"; + +interface OwnProps { + dimensionLabel?: string; +} + +const mapStateToProps = (state: AppState, ownProps: OwnProps) => { + const selectedDimensionLabel = effectiveSelectedDimensionLabelSelector( + state, + ownProps.dimensionLabel, + ); + return { + locale: state.controlState.locale, + selectedVariable: selectedVariableSelector(state), + selectedDimensionLabel, + selectedDimension: selectedDatasetDimensionForLabelSelector( + state, + ownProps.dimensionLabel, + ), + selectedDimensionValue: selectedDatasetDimensionValueForLabelSelector( + state, + ownProps.dimensionLabel, + ), + }; +}; + +const mapDispatchToProps = { + selectDimensionValues, +}; + +const DimensionValueSlider = connect( + mapStateToProps, + mapDispatchToProps, +)(_DimensionValueSlider); +export default DimensionValueSlider; diff --git a/src/connected/StatisticsPanel.tsx b/src/connected/StatisticsPanel.tsx index 3a3ed2d2..8d51b2c8 100644 --- a/src/connected/StatisticsPanel.tsx +++ b/src/connected/StatisticsPanel.tsx @@ -13,6 +13,7 @@ import { selectedDatasetSelector, selectedDatasetTimeLabelSelector, selectedPlaceInfoSelector, + selectedVariableDimensionValuesSelector, selectedVariableSelector, } from "@/selectors/controlSelectors"; import _StatisticsPanel from "@/components/StatisticsPanel"; @@ -25,6 +26,7 @@ const mapStateToProps = (state: AppState) => { selectedDataset: selectedDatasetSelector(state), selectedVariable: selectedVariableSelector(state), selectedTime: selectedDatasetTimeLabelSelector(state), + selectedDimensionValues: selectedVariableDimensionValuesSelector(state), selectedPlaceInfo: selectedPlaceInfoSelector(state), statisticsLoading: statisticsLoadingSelector(state), statisticsRecords: resolvedStatisticsRecordsSelector(state), diff --git a/src/connected/TimePlayer.tsx b/src/connected/TimePlayer.tsx index 1594d0d0..c4758c28 100644 --- a/src/connected/TimePlayer.tsx +++ b/src/connected/TimePlayer.tsx @@ -21,7 +21,7 @@ const mapStateToProps = (state: AppState) => { selectedTime: state.controlState.selectedTime, selectedTimeRange: state.controlState.selectedTimeRange, timeAnimationActive: state.controlState.timeAnimationActive, - timeAnimationInterval: state.controlState.timeAnimationInterval, + dimensionAnimationInterval: state.controlState.dimensionAnimationInterval, }; }; diff --git a/src/ext/store.ts b/src/ext/store.ts index 904975e3..f4777ed0 100644 --- a/src/ext/store.ts +++ b/src/ext/store.ts @@ -19,6 +19,7 @@ import { selectedVariableNameSelector, selectedDatasetTitleSelector, selectedDataset2TitleSelector, + selectedVariableDimensionValuesSelector, } from "@/selectors/controlSelectors"; import { getPaletteMode } from "@/states/controlState"; @@ -88,6 +89,11 @@ export const derivedStateProperties: Record = { selector: (appState: AppState) => getPaletteMode(appState.controlState.themeMode), }, + selectedVariableDimensionValues: { + type: "dict[str, Any] | None", + description: "The selected dimension values of the selected variable.", + selector: selectedVariableDimensionValuesSelector, + }, }; export function newDerivedStore(store: Store): HostStore { diff --git a/src/model/dataset.ts b/src/model/dataset.ts index 6ad87af7..7b9ef7d7 100644 --- a/src/model/dataset.ts +++ b/src/model/dataset.ts @@ -96,6 +96,18 @@ export function findDatasetVariable( ); } +export function findDatasetDimension( + dataset: Dataset, + dimensionName: string | null, +): Dimension | null { + return ( + (dimensionName && + dataset.dimensions.find( + (dimension) => dimension.name === dimensionName, + )) || + null + ); +} export function getDatasetUserVariablesIndex(dataset: Dataset): number { // vIndex is the first index that is a user variable. return dataset.variables.findIndex((v) => isString(v.expression)); @@ -123,7 +135,7 @@ export function getDatasetTimeDimension( assertDefinedAndNotNull(dataset, "dataset"); assertArrayNotEmpty(dataset.dimensions, "dataset.dimensions"); const dimension = dataset.dimensions.find( - (dimension) => dimension.name === "time", + (dimension) => dimension.name === "time", // TODO: add "date", "year" ); if (!dimension) { return null; @@ -292,3 +304,13 @@ export function getDatasetLevel( return undefined; } } + +export function isSpatialDim(dimName: string): boolean { + const normalized = dimName.toLowerCase(); + return ["lat", "lon", "latitude", "longitude", "x", "y"].includes(normalized); +} + +export function isTemporalDim(dimName: string): boolean { + const normalized = dimName.toLowerCase(); + return ["time"].includes(normalized); +} diff --git a/src/model/statistics.ts b/src/model/statistics.ts index 53060044..044a0872 100644 --- a/src/model/statistics.ts +++ b/src/model/statistics.ts @@ -7,12 +7,14 @@ import { Dataset } from "./dataset"; import { Variable } from "./variable"; import { PlaceInfo } from "./place"; +import { DimensionValues } from "@/states/controlState"; export interface StatisticsSource { dataset: Dataset; variable: Variable; time: string | null; placeInfo: PlaceInfo; + dimensionValues: DimensionValues; } export interface Histogram { diff --git a/src/model/timeSeries.ts b/src/model/timeSeries.ts index d2b5ba86..64692e24 100644 --- a/src/model/timeSeries.ts +++ b/src/model/timeSeries.ts @@ -9,6 +9,7 @@ import { parseISO } from "date-fns"; import { utcTimeToIsoDateTimeString } from "@/util/time"; import { findPlaceInPlaceGroups, Place, PlaceGroup } from "./place"; +import { DimensionValues } from "@/states/controlState"; /** * Time is an integer value that is the number of milliseconds since 1 January 1970 UTC (Unix Time Stamp). @@ -34,6 +35,7 @@ export interface TimeSeriesSource { geometry: geojson.Geometry | null; valueDataKey: keyof TimeSeriesPoint; errorDataKey: keyof TimeSeriesPoint | null; + dimensionValues: DimensionValues; } export interface TimeSeriesPoint { @@ -261,6 +263,7 @@ export function placeGroupToTimeSeries( geometry: null, // could be computed later from data points (as GeometryCollection) valueDataKey: "mean", errorDataKey: null, + dimensionValues: {}, }, data: [point], dataProgress: 1.0, diff --git a/src/reducers/controlReducer.ts b/src/reducers/controlReducer.ts index de8151e0..8444fab9 100644 --- a/src/reducers/controlReducer.ts +++ b/src/reducers/controlReducer.ts @@ -12,10 +12,13 @@ import { ControlAction, FLY_TO, INC_SELECTED_TIME, + INC_SELECTED_DIMENSION, OPEN_DIALOG, REMOVE_ACTIVITY, REMOVE_USER_COLOR_BAR, SELECT_DATASET, + SELECT_DIMENSION, + SELECT_DIMENSION_VALUES, SELECT_PLACE, SELECT_PLACE_GROUPS, SELECT_TIME, @@ -41,6 +44,7 @@ import { UPDATE_SETTINGS, UPDATE_SIDE_PANEL_SIZE, UPDATE_TIME_ANIMATION, + UPDATE_DIMENSION_ANIMATION, UPDATE_USER_COLOR_BAR, UPDATE_VARIABLE_SPLIT_POS, UPDATE_VOLUME_STATE, @@ -56,16 +60,26 @@ import { } from "@/actions/dataActions"; import i18n from "@/i18n"; import { + Dataset, findDataset, + findDatasetDimension, findDatasetVariable, getDatasetTimeRange, + isSpatialDim, + isTemporalDim, } from "@/model/dataset"; import { + selectedDatasetSelector, selectedDatasetTimeCoordinatesSelector, selectedDatasetTimeIndexSelector, + selectedDimensionLabelSelector, } from "@/selectors/controlSelectors"; import { AppState } from "@/states/appState"; -import { ControlState, newControlState } from "@/states/controlState"; +import { + ControlState, + DimensionValues, + newControlState, +} from "@/states/controlState"; import { storeUserSettings } from "@/states/userSettings"; import { findIndexCloseTo } from "@/util/find"; import { isNumber } from "@/util/types"; @@ -176,6 +190,11 @@ export function controlReducer( selectedVariableName, selectedTimeRange, selectedTime, + selectedDimensionValues: getSelectedDimensionValuesForVariable( + state.selectedDimensionValues, + selectedDataset, + selectedVariableName, + ), }; } case FLY_TO: { @@ -205,9 +224,18 @@ export function controlReducer( }; } case SELECT_VARIABLE: { + const selectedDataset = appState + ? selectedDatasetSelector(appState) + : null; + return { ...state, selectedVariableName: action.selectedVariableName, + selectedDimensionValues: getSelectedDimensionValuesForVariable( + state.selectedDimensionValues, + selectedDataset, + action.selectedVariableName, + ), }; } case TOGGLE_DATASET_RGB_LAYER: { @@ -308,6 +336,50 @@ export function controlReducer( } return state; } + case INC_SELECTED_DIMENSION: { + if (appState) { + const label = + action.selectedDimensionLabel ?? + selectedDimensionLabelSelector(appState); + const selectedDataset = selectedDatasetSelector(appState); + const dimension = selectedDataset + ? findDatasetDimension(selectedDataset, label) + : null; + const coordinates = dimension?.coordinates ?? null; + const selectedDimensionValue = label + ? state.selectedDimensionValues[label] + : null; + let index = + selectedDimensionValue !== null && + selectedDimensionValue !== undefined && + coordinates + ? findIndexCloseTo(coordinates, selectedDimensionValue as number) + : -1; + if (index >= 0 && coordinates) { + index += action.increment; + if (index < 0) { + index = coordinates.length - 1; + } + if (index > coordinates.length - 1) { + index = 0; + } + const nextSelectedDimensionValue = coordinates[index]; + if ( + label && + state.selectedDimensionValues[label] !== nextSelectedDimensionValue + ) { + return { + ...state, + selectedDimensionValues: { + ...state.selectedDimensionValues, + [label]: nextSelectedDimensionValue, + }, + }; + } + } + } + return state; + } case SELECT_TIME_RANGE: { return { ...state, @@ -324,7 +396,14 @@ export function controlReducer( return { ...state, timeAnimationActive: action.timeAnimationActive, - timeAnimationInterval: action.timeAnimationInterval, + dimensionAnimationInterval: action.dimensionAnimationInterval, + }; + } + case UPDATE_DIMENSION_ANIMATION: { + return { + ...state, + dimensionAnimationActive: action.dimensionAnimationActive, + dimensionAnimationInterval: action.dimensionAnimationInterval, }; } case ADD_DRAWN_USER_PLACE: { @@ -584,6 +663,21 @@ export function controlReducer( datasetZLevel: action.datasetZLevel, }; } + case SELECT_DIMENSION_VALUES: { + return { + ...state, + selectedDimensionValues: { + ...state.selectedDimensionValues, + ...action.selectedDimensionValues, + }, + }; + } + case SELECT_DIMENSION: { + return { + ...state, + selectedDimensionLabel: action.selectedDimensionLabel, + }; + } case CONFIGURE_SERVERS: { if (state.selectedServerId !== action.selectedServerId) { return { ...state, selectedServerId: action.selectedServerId }; @@ -613,3 +707,35 @@ function selectUserPlace( selectedPlaceId: placeId, }; } + +function getSelectedDimensionValuesForVariable( + selectedDimensionValues: DimensionValues, + selectedDataset: Dataset | null | undefined, + selectedVariableName: string | null | undefined, +) { + const nextSelectedDimensionValues = { + ...selectedDimensionValues, + }; + + if (!selectedDataset || !selectedVariableName) { + return nextSelectedDimensionValues; + } + + const selectedVariable = findDatasetVariable( + selectedDataset, + selectedVariableName, + ); + + selectedVariable?.dims?.forEach((dim) => { + if ( + !(dim in nextSelectedDimensionValues) && + !isSpatialDim(dim) && + !isTemporalDim(dim) + ) { + const dimension = findDatasetDimension(selectedDataset, dim); + nextSelectedDimensionValues[dim] = dimension?.coordinates?.[0] ?? null; + } + }); + + return nextSelectedDimensionValues; +} diff --git a/src/reducers/dataReducer.ts b/src/reducers/dataReducer.ts index 52ad610f..6a8809dc 100644 --- a/src/reducers/dataReducer.ts +++ b/src/reducers/dataReducer.ts @@ -469,7 +469,8 @@ function updateTimeSeriesGroups( (ts) => ts.source.datasetId === currentTimeSeries.source.datasetId && ts.source.variableName === currentTimeSeries.source.variableName && - ts.source.placeId === currentTimeSeries.source.placeId, + ts.source.placeId === currentTimeSeries.source.placeId && + ts.source.dimensionValues === currentTimeSeries.source.dimensionValues, ); let newTimeSeriesArray; if (tsIndex >= 0) { diff --git a/src/resources/lang.json b/src/resources/lang.json index afa6fff3..43990c82 100644 --- a/src/resources/lang.json +++ b/src/resources/lang.json @@ -350,6 +350,11 @@ "de": "Informationen über den ausgewählten Ort", "se": "Information om vald plats" }, + { + "en": "Dimension", + "de": "Dimension", + "se": "Dimension" + }, { "en": "Dimension names", "de": "Namen der Dimensionen", @@ -628,7 +633,7 @@ { "en": "Auto-step through times in the dataset", "de": "Zeiten im Datensatz automatisch durchlaufen", - "se": "Kör automatiskt genom tider i dataposten" + "se": "Stega automatiskt genom tider i dataposten" }, { "en": "First time step", @@ -655,6 +660,36 @@ "de": "Datensatz-Zeit auswählen", "se": "Välj tid i dataset" }, + { + "en": "Auto-step through dimension", + "de": "Dimension automatisch durchlaufen", + "se": "Stega automatiskt genom dimensionen" + }, + { + "en": "First step", + "de": "Erster Schritt", + "se": "Första steg" + }, + { + "en": "Last step", + "de": "Letzter Schritt", + "se": "Sista steg" + }, + { + "en": "Previous step", + "de": "Vorheriger Schritt", + "se": "Föregående steg" + }, + { + "en": "Next step", + "de": "Nächster Schritt", + "se": "Nästa steg" + }, + { + "en": "Select value in dimension", + "de": "Wert in der Dimension auswählen", + "se": "Välj värde i dimensionen" + }, { "en": "Refresh", "de": "Aktualisieren", diff --git a/src/selectors/controlSelectors.tsx b/src/selectors/controlSelectors.tsx index e794d04c..d7ff19f4 100644 --- a/src/selectors/controlSelectors.tsx +++ b/src/selectors/controlSelectors.tsx @@ -30,7 +30,9 @@ import { MapElement } from "@/components/ol/Map"; import { Dataset, + Dimension, findDataset, + findDatasetDimension, findDatasetVariable, getDatasetLevel, getDatasetTimeDimension, @@ -63,6 +65,7 @@ import { } from "./dataSelectors"; import { makeRequestUrl } from "@/api/callApi"; import { + DimensionValues, LayerStates, LayerVisibilities, MAP_OBJECTS, @@ -157,6 +160,12 @@ export const zoomLevelSelector = (state: AppState) => state.controlState.zoomLevel; export const selectedDatasetZLevelSelector = (state: AppState) => state.controlState.datasetZLevel; +export const selectedDimensionLabelSelector = (state: AppState) => + state.controlState.selectedDimensionLabel; +export const selectedDimensionValuesSelector = (state: AppState) => + state.controlState.selectedDimensionValues; +export const showAllDimensionsSelector = (state: AppState) => + state.controlState.showAllDimensions; const variableLayerIdSelector = () => "variable"; const variable2LayerIdSelector = () => "variable2"; @@ -207,6 +216,13 @@ export const selectedUserVariablesSelector = createSelector( }, ); +export const selectedDimensionSelector = createSelector( + selectedDatasetSelector, + (dataset: Dataset | null): Dimension[] => { + return (dataset && dataset.dimensions) || []; + }, +); + export const getDatasetResolutions = (dataset: Dataset | null): number[] => dataset && dataset.resolutions ? dataset.resolutions : []; @@ -255,6 +271,85 @@ export const selectedVariable2Selector = createSelector( _findDatasetVariable, ); +const _findDatasetDimension = ( + dataset: Dataset | null, + dimName: string | null, +): Dimension | null => { + if (!dataset || !dimName) { + return null; + } + return findDatasetDimension(dataset, dimName); +}; + +export const selectedDatasetDimensionSelector = createSelector( + selectedDatasetSelector, + selectedDimensionLabelSelector, + _findDatasetDimension, +); + +export const selectedDatasetDimensionValueSelector = createSelector( + selectedDimensionValuesSelector, + selectedDimensionLabelSelector, + (values: DimensionValues, label: string | null): string | number | null => { + if (label === null) { + return null; + } + + return values[label] ?? null; + }, +); + +const dimensionLabelArgumentSelector = ( + _state: AppState, + dimensionLabel?: string | null, +) => dimensionLabel ?? null; + +const _getEffectiveSelectedDimensionLabel = ( + selectedDimensionLabel: string | null, + dimensionLabel: string | null, +): string | null => dimensionLabel ?? selectedDimensionLabel; + +export const effectiveSelectedDimensionLabelSelector = createSelector( + selectedDimensionLabelSelector, + dimensionLabelArgumentSelector, + _getEffectiveSelectedDimensionLabel, +); + +export const selectedDatasetDimensionForLabelSelector = createSelector( + selectedDatasetSelector, + effectiveSelectedDimensionLabelSelector, + _findDatasetDimension, +); + +export const selectedDatasetDimensionValueForLabelSelector = createSelector( + selectedDimensionValuesSelector, + effectiveSelectedDimensionLabelSelector, + (values: DimensionValues, label: string | null): string | number | null => { + if (label === null) { + return null; + } + + return values[label] ?? null; + }, +); + +export const selectedVariableDimensionValuesSelector = createSelector( + selectedVariableSelector, + selectedDimensionValuesSelector, + (variable: Variable | null, dimensionValues): DimensionValues => { + const selectedDimensionLabels = variable?.dims + ?.filter((dim) => dimensionValues?.[dim] != null) + .reduce( + (result, dim) => ({ + ...result, + [dim]: dimensionValues[dim], + }), + {}, + ); + return selectedDimensionLabels || {}; + }, +); + const getVariableTitle = (variable: Variable | null): string | null => { return variable && (variable.title || variable.name); }; @@ -602,11 +697,13 @@ export const canAddTimeSeriesSelector = createSelector( selectedDatasetIdSelector, selectedVariableNameSelector, selectedPlaceIdSelector, + selectedVariableDimensionValuesSelector, ( timeSeriesGroups: TimeSeriesGroup[], datasetId: string | null, variableName: string | null, placeId: string | null, + dimensionValues: DimensionValues, ): boolean => { if (!datasetId || !variableName || !placeId) { return false; @@ -617,7 +714,8 @@ export const canAddTimeSeriesSelector = createSelector( if ( source.datasetId === datasetId && source.variableName === variableName && - source.placeId === placeId + source.placeId === placeId && + source.dimensionValues === dimensionValues ) { return false; } @@ -785,6 +883,47 @@ export const selectedDataset2TimeIndexSelector = createSelector( _getTimeIndex, ); +const _getDimensionCoordinates = ( + dimension: Dimension | null, +): number[] | null => { + if (dimension === null || dimension.coordinates.length === 0) { + return null; + } + return dimension.coordinates; +}; + +export const selectedDatasetDimensionCoordinatesSelector = createSelector( + selectedDatasetDimensionSelector, + _getDimensionCoordinates, +); + +export const selectedDataset2DimensionCoordinatesSelector = createSelector( + selectedDatasetDimensionSelector, + _getDimensionCoordinates, +); + +const _getDimensionIndex = ( + value: number | string | null, + dimensionCoordinates: number[] | null, +): number => { + if (value === null || dimensionCoordinates === null) { + return -1; + } + return findIndexCloseTo(dimensionCoordinates, value as number); //TODO `as number` should be removed/unnecessary +}; + +export const selectedDatasetDimensionIndexSelector = createSelector( + selectedDatasetDimensionValueSelector, + selectedDatasetDimensionCoordinatesSelector, + _getDimensionIndex, +); + +export const selectedDataset2DimensionIndexSelector = createSelector( + selectedDatasetDimensionValueSelector, + selectedDataset2DimensionCoordinatesSelector, + _getDimensionIndex, +); + const _getTimeLabel = ( time: Time | null, timeIndex: number, @@ -928,6 +1067,7 @@ function getTileLayer( queryParams = [...queryParams, ["time", timeLabel]]; } const url = makeRequestUrl(tileUrl, queryParams); + if (typeof tileLevelMax === "number") { // It is ok to have some extra zoom levels, so we can magnify pixels. // Using more, artifacts will become visible. @@ -1076,6 +1216,7 @@ const getVariableTileLayer = ( timeAnimationActive: boolean, mapProjection: string, imageSmoothing: boolean, + selectedDimensionValues: DimensionValues, ): MapElement => { if (!dataset || !variable || !visibility) { return null; @@ -1090,6 +1231,13 @@ const getVariableTileLayer = ( if (colorBarNorm === "log") { queryParams.push(["norm", colorBarNorm]); } + + Object.entries(selectedDimensionValues).forEach(([name, value]) => { + if (value != null) { + queryParams.push([name, String(value)]); + } + }); + return getTileLayer( layerId, getTileUrl(server.url, dataset, variable), @@ -1125,6 +1273,7 @@ export const selectedDatasetVariableLayerSelector = createSelector( timeAnimationActiveSelector, mapProjectionSelector, imageSmoothingSelector, + selectedDimensionValuesSelector, getVariableTileLayer, ); @@ -1145,6 +1294,7 @@ export const selectedDatasetVariable2LayerSelector = createSelector( timeAnimationActiveSelector, mapProjectionSelector, imageSmoothingSelector, + selectedDimensionValuesSelector, getVariableTileLayer, ); diff --git a/src/states/controlState.ts b/src/states/controlState.ts index 99257a07..d9b40d1b 100644 --- a/src/states/controlState.ts +++ b/src/states/controlState.ts @@ -23,8 +23,8 @@ import { loadUserSettings } from "./userSettings"; import { PaletteMode } from "@mui/material"; import { LayerState } from "@/model/layerState"; -export type TimeAnimationInterval = 250 | 500 | 1000 | 2500; -export const TIME_ANIMATION_INTERVALS: TimeAnimationInterval[] = [ +export type DimensionAnimationInterval = 250 | 500 | 1000 | 2500; +export const DIMENSION_ANIMATION_INTERVALS: DimensionAnimationInterval[] = [ 250, 500, 1000, 2500, ]; @@ -66,6 +66,8 @@ export type LayerGroupStates = { baseMaps?: boolean; }; +export type DimensionValues = Record; + export type ThemeMode = PaletteMode | "system"; export const THEME_NAMES: ThemeMode[] = ["light", "dark", "system"]; export const THEME_LABELS: [ThemeMode, string][] = [ @@ -93,7 +95,6 @@ export interface ControlState { selectedTimeRange: TimeRange | null; timeSeriesUpdateMode: "add" | "replace"; timeAnimationActive: boolean; - timeAnimationInterval: TimeAnimationInterval; timeChunkSize: number; autoShowTimeSeries: boolean; timeSeriesChartTypeDefault: TimeSeriesChartType; @@ -140,6 +141,11 @@ export interface ControlState { showZoomInfoBox: boolean; zoomLevel: number | undefined; datasetZLevel: number | undefined; + dimensionAnimationInterval: DimensionAnimationInterval; + dimensionAnimationActive: boolean; + selectedDimensionLabel: string | null; + selectedDimensionValues: DimensionValues; + showAllDimensions: boolean; } export function newControlState(): ControlState { @@ -157,7 +163,6 @@ export function newControlState(): ControlState { selectedTimeRange: null, timeSeriesUpdateMode: "add", timeAnimationActive: false, - timeAnimationInterval: 1000, timeChunkSize: 20, autoShowTimeSeries: true, timeSeriesChartTypeDefault: "line", @@ -225,6 +230,11 @@ export function newControlState(): ControlState { showZoomInfoBox: branding.showZoomInfoBox || false, zoomLevel: undefined, datasetZLevel: undefined, + dimensionAnimationInterval: 1000, + dimensionAnimationActive: false, + selectedDimensionLabel: null, + selectedDimensionValues: {}, + showAllDimensions: false, }; return loadUserSettings(state); } diff --git a/src/states/persistedState.ts b/src/states/persistedState.ts index f6f9b2fa..36633a34 100644 --- a/src/states/persistedState.ts +++ b/src/states/persistedState.ts @@ -41,6 +41,7 @@ const controlStateProps: readonly (keyof ControlState)[] = [ "userColorBars", "mapProjection", "zoomLevel", + "selectedDimensionValues", ]; type PersistedDataState = Pick; diff --git a/src/states/userSettings.ts b/src/states/userSettings.ts index b487c11b..6809ae72 100644 --- a/src/states/userSettings.ts +++ b/src/states/userSettings.ts @@ -70,7 +70,7 @@ export function storeUserSettings(settings: ControlState) { storage.setPrimitiveProperty("timeSeriesIncludeStdev", settings); storage.setPrimitiveProperty("timeSeriesChartTypeDefault", settings); storage.setPrimitiveProperty("timeSeriesUseMedian", settings); - storage.setPrimitiveProperty("timeAnimationInterval", settings); + storage.setPrimitiveProperty("dimensionAnimationInterval", settings); storage.setPrimitiveProperty("timeChunkSize", settings); storage.setPrimitiveProperty("sidePanelOpen", settings); storage.setPrimitiveProperty("sidePanelId", settings); @@ -98,6 +98,7 @@ export function storeUserSettings(settings: ControlState) { storage.setPrimitiveProperty("exportResolution", settings); storage.setPrimitiveProperty("showProgressBar", settings); storage.setPrimitiveProperty("showZoomInfoBox", settings); + storage.setPrimitiveProperty("showAllDimensions", settings); if (import.meta.env.DEV) { console.debug("Stored user settings:", settings); } @@ -139,7 +140,7 @@ export function loadUserSettings(defaultSettings: ControlState): ControlState { defaultSettings, ); storage.getIntProperty( - "timeAnimationInterval", + "dimensionAnimationInterval", settings, defaultSettings, ); @@ -201,6 +202,11 @@ export function loadUserSettings(defaultSettings: ControlState): ControlState { ); storage.getStringProperty("themeMode", settings, defaultSettings); storage.getStringProperty("exportResolution", settings, defaultSettings); + storage.getBooleanProperty( + "showAllDimensions", + settings, + defaultSettings, + ); if (import.meta.env.DEV) { console.debug("Loaded user settings:", settings); }