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
{: class="light-image" }
{: 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).

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

+---
+
+## 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.
+
+
+
+**All dimensions**: Selectors for all available non-spatial dimensions are shown simultaneously.
+
+
+
+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 = (
+ }
+ displayEmpty
+ name="selectedDimension"
+ renderValue={(value) => value || "Select dimension"}
+ >
+ {nonSpatialDims.map((values) => {
+ return (
+
+
+
+ );
+ })}
+
+ );
+
+ 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 = (
+ }
+ displayEmpty
+ name="Dimension"
+ renderValue={(value) => value || "Select coordinate"}
+ sx={{ width: "150px" }}
+ >
+ {(selectedDimension.coordinates || []).map((coordinate) => {
+ return (
+
+
+
+ );
+ })}
+
+ );
+
+ 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);
}