diff --git a/CHANGES.md b/CHANGES.md
index f1369eae..efcbfc2b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -20,15 +20,15 @@
* A pin icon is now used in the dataset selector to mark a dataset
that holds a pinned variable. (#424)
-* Slimmed down the main application bar by grouping features under a
- single button that opens a dropdown menu. The features are:
- Documentation, Developer Reference, Imprint, and Settings. (#540)
+* Slimmed down the main application bar by grouping features under a
+ single button that opens a dropdown menu. The features are:
+ Documentation, Developer Reference, Imprint, and Settings. (#540)
### Fixes
-* Applied workaround for a bug in `html-to-image` libary that causes an
- issuesfor the export of screenshorts (charts and map) in the Firefox
- browser.
+* Applied workaround for a bug in `html-to-image` libary that causes an
+ issue for the export of screenshorts (charts and map) in the Firefox
+ browser.
### New Features
@@ -42,10 +42,17 @@
Window. If this feature is configured, an `About` window can be opened
with a button in the header and it will be shown initially while
data is loading. (#508)
+
+* A zoom-level indicator was added to the map. This box displays the current
+ zoom level of the map and the dataset resolution level used for displaying it.
+ The visibility of this feature can be controlled in the settings. The initial visibility
+ can be set in `config.json` (`"branding":{ "showZoomInfoBox": true, ...`),
+ the default is `false`. In addition, the total number of dataset levels has been
+ added to the metadata in the Info panel. (#287)
### Other changes
-* Aligned styling of map elements (Zoom, MapActionsBar, ScaleBar, Attritution,
+* Aligned styling of map elements (Zoom, MapActionsBar, ScaleBar, Attribution,
ColorLegend) to match styling of the rest of the Viewer. (#545).
* Redux Developement tools is now available in development mode. Installation of
diff --git a/docs/assets/images/zoom_infobox.png b/docs/assets/images/zoom_infobox.png
new file mode 100644
index 00000000..e66657d4
Binary files /dev/null and b/docs/assets/images/zoom_infobox.png differ
diff --git a/docs/features.md b/docs/features.md
index 6fdc5eed..edab4a05 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -493,6 +493,52 @@ A list of all the features that the viewer contains will be created here, in whi
+### Zoom Information Box
+
+
+
+
+
+
+ |
+
+
+
+
+ | Feature Name |
+ Zoom Information Box |
+
+
+ | Description |
+
+ Displays an information box that showing the current
+ zoom level of the map and the dataset resolution level (particularly
+ useful for multi-resolution dataset) used for displaying the dataset
+ in the map.
+ |
+
+
+
+ | Functionality |
+
+ The visibility of this feature can be controlled in the
+ settings.
+ The initial visibility can be set in the Viewer configuration.
+ |
+
+
+ | Aim |
+ Enable users to quickly access information about the zoom and dataset
+ level.
+ |
+
+
+
+
### Share Permalink
diff --git a/src/actions/controlActions.tsx b/src/actions/controlActions.tsx
index 3fee7bd2..165237b4 100644
--- a/src/actions/controlActions.tsx
+++ b/src/actions/controlActions.tsx
@@ -823,6 +823,34 @@ export function updateUserColorBars(
////////////////////////////////////////////////////////////////////////////////
+export const SET_ZOOM_LEVEL = "SET_ZOOM_LEVEL";
+
+export interface SetZoomLevel {
+ type: typeof SET_ZOOM_LEVEL;
+ zoomLevel: number | undefined;
+}
+
+export function setZoomLevel(zoomLevel: number | undefined): SetZoomLevel {
+ return { type: SET_ZOOM_LEVEL, zoomLevel };
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+export const SET_DATASET_Z_LEVEL = "SET_DATASET_Z_LEVEL";
+
+export interface SetDatasetZLevel {
+ type: typeof SET_DATASET_Z_LEVEL;
+ datasetZLevel: number | undefined;
+}
+
+export function setDatasetZLevel(
+ datasetZLevel: number | undefined,
+): SetDatasetZLevel {
+ return { type: SET_DATASET_Z_LEVEL, datasetZLevel };
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
export type ControlAction =
| SelectDataset
| UpdateDatasetPlaceGroup
@@ -860,4 +888,6 @@ export type ControlAction =
| SetMapPointInfoBoxEnabled
| SetVariableCompareMode
| UpdateVariableSplitPos
- | FlyTo;
+ | FlyTo
+ | SetZoomLevel
+ | SetDatasetZLevel;
diff --git a/src/components/InfoPanel/DatasetInfoCard.tsx b/src/components/InfoPanel/DatasetInfoCard.tsx
index 9c66275d..b13dd91c 100644
--- a/src/components/InfoPanel/DatasetInfoCard.tsx
+++ b/src/components/InfoPanel/DatasetInfoCard.tsx
@@ -95,6 +95,7 @@ const DatasetInfoCard: React.FC = ({
dataset.bbox.map((x) => getLabelForValue(x, 3)).join(", "),
],
[i18n.get("Spatial reference system"), dataset.spatialRef],
+ [i18n.get("Levels"), dataset.resolutions.length],
];
content = (
diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx
index cc99155b..f5ece048 100644
--- a/src/components/SettingsDialog.tsx
+++ b/src/components/SettingsDialog.tsx
@@ -387,6 +387,16 @@ const SettingsDialog: React.FC = ({
updateSettings={updateSettings}
/>
+
+
+
void;
importUserPlacesFromText?: (text: string) => void;
+ setZoomLevel?: (zoomLevel: number | undefined) => void;
+ setDatasetZLevel?: (datasetZLevel: number | undefined) => void;
+ zoomBox?: MapElement;
}
export default function Viewer({
@@ -158,6 +163,9 @@ export default function Viewer({
imageSmoothing,
variableSplitPos,
onMapRef,
+ zoomBox,
+ setZoomLevel,
+ setDatasetZLevel,
}: ViewerProps) {
theme = useTheme();
@@ -355,11 +363,41 @@ export default function Viewer({
console.log("tile load progress:", p);
}, []);
+ const handleMapZoom = (
+ event: OlMapBrowserEvent,
+ map: OlMap | undefined,
+ ) => {
+ if (setZoomLevel) {
+ const zoomLevel = event.target.getZoom();
+ setZoomLevel(zoomLevel);
+ }
+
+ if (setDatasetZLevel) {
+ const datasetZLevel = getDatasetZLevel(event.target, map);
+ setDatasetZLevel(datasetZLevel);
+ }
+ };
+
+ useEffect(() => {
+ /* Force update of datasetZLevel after variable change. This is needed at
+ the moment and might become redundant in the future.
+ This ensures that datasetZLevel gets set, when the Viewer starts, so that
+ the datasetLevel can be calculated.
+ */
+ if (map) {
+ if (setDatasetZLevel) {
+ const datasetZLevel = getDatasetZLevel(map.getView(), map);
+ setDatasetZLevel(datasetZLevel);
+ }
+ }
+ }, [map, variableLayer, setDatasetZLevel]);
+
return (
diff --git a/src/components/ZoomInfoBox/ZoomInfoBox.stories.tsx b/src/components/ZoomInfoBox/ZoomInfoBox.stories.tsx
new file mode 100644
index 00000000..c7776e55
--- /dev/null
+++ b/src/components/ZoomInfoBox/ZoomInfoBox.stories.tsx
@@ -0,0 +1,31 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import ZoomInfoBox from "./ZoomInfoBox";
+
+const meta: Meta = {
+ component: ZoomInfoBox,
+ title: "ZoomInfoBox",
+ parameters: {
+ // Optional parameter to center the component in the Canvas.
+ // More info: https://storybook.js.org/docs/configure/story-layout
+ layout: "centered",
+ },
+ // This component will have an automatically generated Autodocs entry:
+ // https://storybook.js.org/docs/writing-docs/autodocs
+ tags: ["autodocs"],
+} satisfies Meta;
+
+// noinspection JSUnusedGlobalSymbols
+export default meta;
+
+type Story = StoryObj;
+
+// noinspection JSUnusedGlobalSymbols
+export const Default: Story = {
+ args: {
+ style: {},
+ zoomLevel: 10,
+ datasetLevel: 3,
+ datasetLevels: 4,
+ visibility: true,
+ },
+};
diff --git a/src/components/ZoomInfoBox/ZoomInfoBox.tsx b/src/components/ZoomInfoBox/ZoomInfoBox.tsx
new file mode 100644
index 00000000..09ca6f9c
--- /dev/null
+++ b/src/components/ZoomInfoBox/ZoomInfoBox.tsx
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2019-2025 by xcube team and contributors
+ * Permissions are hereby granted under the terms of the MIT License:
+ * https://opensource.org/licenses/MIT.
+ */
+
+import { CSSProperties } from "react";
+import { alpha } from "@mui/material/styles";
+import Box from "@mui/material/Box";
+import Divider from "@mui/material/Divider";
+import Typography from "@mui/material/Typography";
+
+import { getLabelForValue } from "@/util/label";
+import { makeStyles } from "@/util/styles";
+import { getBorderStyle } from "@/components/ColorBarLegend/style";
+
+const styles = makeStyles({
+ container: (theme) => ({
+ position: "absolute",
+ zIndex: 1000,
+ border: getBorderStyle(theme),
+ borderRadius: "4px",
+ backgroundColor: alpha(theme.palette.background.default, 0.85),
+ minWidth: "120px",
+ paddingLeft: theme.spacing(1.5),
+ paddingRight: theme.spacing(1.5),
+ paddingBottom: theme.spacing(0.5),
+ paddingTop: theme.spacing(0.5),
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ gap: 1,
+ }),
+ title: {
+ fontSize: "0.8rem",
+ fontWeight: "normal",
+ wordBreak: "break-word",
+ wordWrap: "break-word",
+ },
+ subTitle: {
+ fontSize: "0.7rem",
+ fontWeight: "lighter",
+ wordBreak: "break-word",
+ wordWrap: "break-word",
+ },
+});
+
+interface ZoomInfoBoxProps {
+ style: CSSProperties;
+ zoomLevel: number | undefined;
+ datasetLevel: number | undefined;
+ datasetLevels: number;
+ visibility: boolean;
+}
+
+export default function ZoomInfoBox({
+ style,
+ zoomLevel,
+ datasetLevel,
+ datasetLevels,
+ visibility,
+}: ZoomInfoBoxProps): JSX.Element | null {
+ if (!visibility) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {"Zoom"}
+
+
+ {zoomLevel !== undefined
+ ? getLabelForValue(zoomLevel, 4)
+ : "no zoom level"}
+
+
+
+
+
+ {"Level"}
+
+
+ {/* increment datasetLevel with +1, so that the dataset level range
+ starts with 1 instead of 0.*/}
+ {datasetLevel !== undefined
+ ? getLabelForValue(datasetLevel + 1, 0) +
+ " / " +
+ getLabelForValue(datasetLevels, 0)
+ : "no dataset level"}
+
+
+
+
+ );
+}
diff --git a/src/components/ZoomInfoBox/index.tsx b/src/components/ZoomInfoBox/index.tsx
new file mode 100644
index 00000000..f41e1b44
--- /dev/null
+++ b/src/components/ZoomInfoBox/index.tsx
@@ -0,0 +1,9 @@
+/*
+ * Copyright (c) 2019-2025 by xcube team and contributors
+ * Permissions are hereby granted under the terms of the MIT License:
+ * https://opensource.org/licenses/MIT.
+ */
+
+import ZoomInfoBox from "./ZoomInfoBox";
+
+export default ZoomInfoBox;
diff --git a/src/components/ol/Map.tsx b/src/components/ol/Map.tsx
index 84815151..54331035 100644
--- a/src/components/ol/Map.tsx
+++ b/src/components/ol/Map.tsx
@@ -50,6 +50,7 @@ interface MapProps extends OlMapOptions {
children?: React.ReactNode;
mapObjects?: { [id: string]: OlBaseObject };
onClick?: (event: OlMapBrowserEvent) => void;
+ onZoom?: (event: OlMapBrowserEvent, map: OlMap | undefined) => void;
onMapRef?: (map: OlMap | null) => void;
isStale?: boolean;
onDropFiles?: (files: File[]) => void;
@@ -83,6 +84,7 @@ export class Map extends React.Component {
private loadStartEventsKey: OlEventsKey | null = null;
private loadEndEventsKey: OlEventsKey | null = null;
private lastTileLoadProgress: TileLoadProgress | null = null;
+ private zoomEventsKey: OlEventsKey | null = null;
constructor(props: MapProps) {
super(props);
@@ -115,6 +117,7 @@ export class Map extends React.Component {
const mapDiv = this.contextValue.mapDiv!;
let map: OlMap | null = null;
+ let view: OlView | null = null;
if (this.props.isStale) {
const mapObject = this.contextValue.mapObjects[id];
if (mapObject instanceof OlMap) {
@@ -123,6 +126,10 @@ export class Map extends React.Component {
if (this.clickEventsKey) {
map.un("click", this.clickEventsKey.listener);
}
+ view = map?.getView();
+ if (this.zoomEventsKey) {
+ view.un("change:resolution", this.zoomEventsKey.listener);
+ }
}
}
@@ -141,13 +148,15 @@ export class Map extends React.Component {
});
}
+ view = map?.getView();
+
this.contextValue.map = map;
this.contextValue.mapObjects[id] = map;
this.clickEventsKey = map.on("click", this.handleClick);
this.loadStartEventsKey = map.on("loadstart", this.handleMapLoadStart);
this.loadEndEventsKey = map.on("loadend", this.handleMapLoadEnd);
-
+ this.zoomEventsKey = view.on("change:resolution", this.handleZoom);
//map.set('objectId', this.props.id);
map.updateSize();
@@ -213,6 +222,7 @@ export class Map extends React.Component {
const mapOptions = { ...this.props };
delete mapOptions["children"];
delete mapOptions["onClick"];
+ delete mapOptions["onZoom"];
delete mapOptions["onDropFiles"];
delete mapOptions["onTileLoadProgress"];
return mapOptions;
@@ -289,6 +299,14 @@ export class Map extends React.Component {
return 0;
};
+ private handleZoom = (event: OlEvent) => {
+ const onZoom = this.props.onZoom;
+ const map = this.contextValue.map;
+ if (onZoom) {
+ onZoom(event as OlMapBrowserEvent, map);
+ }
+ };
+
private handleMapLoadStart = () => {
this.resetProgressState();
};
diff --git a/src/connected/Viewer.tsx b/src/connected/Viewer.tsx
index ea9e55e0..e202615a 100644
--- a/src/connected/Viewer.tsx
+++ b/src/connected/Viewer.tsx
@@ -29,12 +29,17 @@ import {
} from "@/actions/dataActions";
import _Viewer from "@/components/Viewer";
import { userPlaceGroupsSelector } from "@/selectors/dataSelectors";
-import { selectPlace } from "@/actions/controlActions";
+import {
+ selectPlace,
+ setDatasetZLevel,
+ setZoomLevel,
+} from "@/actions/controlActions";
import ColorBarLegend from "./ColorBarLegend";
import ColorBarLegend2 from "./ColorBarLegend2";
import MapSplitter from "./MapSplitter";
import MapPointInfoBox from "./MapPointInfoBox";
import MapControlActions from "./MapControlActions";
+import ZoomInfoBox from "./ZoomInfoBox";
interface OwnProps {
onMapRef?: (map: OlMap | null) => void;
@@ -68,6 +73,7 @@ const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
imageSmoothing: imageSmoothingSelector(state),
variableSplitPos: state.controlState.variableSplitPos,
onMapRef: ownProps.onMapRef,
+ zoomBox: ,
};
};
@@ -76,6 +82,8 @@ const mapDispatchToProps = {
addDrawnUserPlace,
importUserPlacesFromText,
selectPlace,
+ setZoomLevel,
+ setDatasetZLevel,
};
const Viewer = connect(mapStateToProps, mapDispatchToProps)(_Viewer);
diff --git a/src/connected/ZoomInfoBox.tsx b/src/connected/ZoomInfoBox.tsx
new file mode 100644
index 00000000..208ba637
--- /dev/null
+++ b/src/connected/ZoomInfoBox.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2019-2025 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 { setZoomLevel } from "@/actions/controlActions";
+import _ZoomInfoBox from "@/components/ZoomInfoBox/ZoomInfoBox";
+import {
+ selectedDatasetLevelSelector,
+ selectedDatasetResolutionsSelector,
+ zoomLevelSelector,
+} from "@/selectors/controlSelectors";
+import { AppState } from "@/states/appState";
+
+const mapStateToProps = (state: AppState) => {
+ return {
+ style: { left: "0.5em", bottom: 40 },
+ zoomLevel: zoomLevelSelector(state),
+ datasetLevel: selectedDatasetLevelSelector(state),
+ datasetLevels: selectedDatasetResolutionsSelector(state).length,
+ visibility: state.controlState.showZoomInfoBox,
+ };
+};
+
+const mapDispatchToProps = { setZoomLevel };
+
+const ZoomInfoBox = connect(mapStateToProps, mapDispatchToProps)(_ZoomInfoBox);
+export default ZoomInfoBox;
diff --git a/src/index.tsx b/src/index.tsx
index 95acd799..0945d1c8 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -24,6 +24,8 @@ import {
changeLocale,
UPDATE_SIDE_PANEL_SIZE,
UPDATE_VARIABLE_SPLIT_POS,
+ SET_DATASET_Z_LEVEL,
+ SET_ZOOM_LEVEL,
updateUserColorBarsImageData,
} from "@/actions/controlActions";
import { syncWithServer } from "@/actions/dataActions";
@@ -36,7 +38,9 @@ console.debug("baseUrl:", baseUrl);
Config.load().then(async () => {
const actionFilter = (_getState: () => AppState, action: Action) =>
action.type !== UPDATE_VARIABLE_SPLIT_POS &&
- action.type !== UPDATE_SIDE_PANEL_SIZE;
+ action.type !== UPDATE_SIDE_PANEL_SIZE &&
+ action.type !== SET_ZOOM_LEVEL &&
+ action.type !== SET_DATASET_Z_LEVEL;
const logger = ReduxLogger.createLogger({
collapsed: true,
diff: false,
diff --git a/src/model/dataset.ts b/src/model/dataset.ts
index 96147422..7547cc3d 100644
--- a/src/model/dataset.ts
+++ b/src/model/dataset.ts
@@ -4,10 +4,16 @@
* https://opensource.org/licenses/MIT.
*/
-import { assertArrayNotEmpty, assertDefinedAndNotNull } from "@/util/assert";
-import { isString } from "@/util/types";
+import OlTileLayer from "ol/layer/Tile";
+import { default as OlMap } from "ol/Map";
+import { default as OlView } from "ol/View";
+
+import { findMapLayer } from "@/components/ol/util";
+import { GEOGRAPHIC_CRS, WEB_MERCATOR_CRS } from "@/model/proj";
import { type UserVariable } from "@/model/userVariable";
import { type JsonPrimitive } from "@/util/json";
+import { assertArrayNotEmpty, assertDefinedAndNotNull } from "@/util/assert";
+import { isString } from "@/util/types";
import { type PlaceGroup } from "./place";
import { type TimeRange } from "./timeSeries";
import { type Variable } from "./variable";
@@ -64,6 +70,8 @@ export interface Dataset {
attributions?: string[];
attrs: Record;
rgbSchema?: RgbSchema;
+ resolutions: number[];
+ spatialUnits: string;
}
export function findDataset(
@@ -136,3 +144,150 @@ export function getDatasetTimeRange(dataset: Dataset): TimeRange | null {
const coordinates = timeDimension.coordinates;
return [coordinates[0], coordinates[coordinates.length - 1]];
}
+
+// this returns the level of the current OLTileLayer of the selected variable
+export const getDatasetZLevel = (
+ view: OlView,
+ map: OlMap | undefined,
+): number | undefined => {
+ if (map) {
+ const resolution = view.getResolution();
+ const layer = findMapLayer(map, "variable");
+ if (layer instanceof OlTileLayer) {
+ const source = layer.getSource();
+ const tileGrid = source.getTileGrid();
+ return tileGrid.getZForResolution(resolution);
+ } else {
+ return undefined;
+ }
+ }
+};
+
+export function isMeterUnit(unitName: string): boolean {
+ const normalized = unitName.toLowerCase();
+ return ["m", "metre", "metres", "meter", "meters"].includes(normalized);
+}
+
+export function isDegreeUnit(unitName: string): boolean {
+ const normalized = unitName.toLowerCase();
+ return [
+ "°",
+ "deg",
+ "degree",
+ "degrees",
+ "decimal_degree",
+ "decimal_degrees",
+ ].includes(normalized);
+}
+
+// Get the factor to convert from one unit into another
+// with units given by *unitNameFrom* and *unitNameTo*.
+export function getUnitFactor(
+ unitNameFrom: string,
+ unitNameTo: string,
+): number {
+ const EARTH_EQUATORIAL_RADIUS_WGS84 = 6378137.0;
+ const EARTH_CIRCUMFERENCE_WGS84 = 2 * Math.PI * EARTH_EQUATORIAL_RADIUS_WGS84;
+
+ const fromMeter = unitNameFrom === WEB_MERCATOR_CRS;
+ const fromDegree = unitNameFrom === GEOGRAPHIC_CRS;
+
+ if (!fromMeter && !fromDegree) {
+ throw new Error(
+ `Unsupported unit '${unitNameFrom}'. Unit must be either meters or degrees.`,
+ );
+ }
+
+ const toMeter = isMeterUnit(unitNameTo);
+ const toDegree = isDegreeUnit(unitNameTo);
+
+ if (!toMeter && !toDegree) {
+ throw new Error(
+ `Unsupported unit '${unitNameTo}'. Unit must be either meters or degrees.`,
+ );
+ }
+
+ if (fromMeter && toDegree) {
+ return 360 / EARTH_CIRCUMFERENCE_WGS84;
+ }
+
+ if (fromDegree && toMeter) {
+ return EARTH_CIRCUMFERENCE_WGS84 / 360;
+ }
+
+ return 1.0; // same units or unsupported conversion
+}
+
+/**
+ * Get the level in the sequence of spatial *resolutions* for a given *map_level*.
+ *
+ * The resolutions are typically those of a multi-resolution dataset,
+ * where the first entry represents level zero — the highest resolution
+ * (i.e. the smallest resolution value). Subsequent resolution values
+ * are monotonically increasing.
+ *
+ * @param datasetZLevel - A level within this tiling scheme.
+ * @param datasetResolutions - A sequence of spatial resolutions. Values must
+ * be monotonically increasing. The first entry is the highest resolution
+ * at level zero.
+ * @param datasetSpatialUnit - The spatial units for the resolutions.
+ * @param mapProjection - The projection of the map.
+ * @returns The multi-resolution level.
+ *
+ * @see https://github.com/xcube-dev/xcube/blob/main/xcube/core/tilingscheme.py#L281
+ */
+export function getDatasetLevel(
+ datasetResolutions: number[],
+ datasetSpatialUnit: string | null,
+ datasetZLevel: number | undefined,
+ mapProjection: string,
+): number | undefined {
+ if (
+ datasetResolutions &&
+ datasetSpatialUnit &&
+ datasetZLevel &&
+ mapProjection
+ ) {
+ // Resolution at level 0
+ let levelZeroResolution: number;
+ if (mapProjection === WEB_MERCATOR_CRS) {
+ levelZeroResolution = 40075017 / 256;
+ } else {
+ levelZeroResolution = 180 / 256;
+ }
+
+ const fFromMap = getUnitFactor(mapProjection, datasetSpatialUnit);
+ // Tile pixel size in dataset units for map tile at level 0
+ const dsPixSizeL0 = fFromMap * levelZeroResolution;
+ // Tile pixel size in dataset units for map tile at level
+ const dsPixSize = dsPixSizeL0 / (1 << datasetZLevel);
+
+ const numDsLevels = datasetResolutions.length;
+
+ const dsPixSizeMin = datasetResolutions[0];
+ if (dsPixSize <= dsPixSizeMin) {
+ return 0;
+ }
+
+ const dsPixSizeMax = datasetResolutions[datasetResolutions.length - 1];
+ if (dsPixSize >= dsPixSizeMax) {
+ return numDsLevels - 1;
+ }
+
+ for (let dsLevel = 0; dsLevel < numDsLevels - 1; dsLevel++) {
+ const dsPixSize1 = datasetResolutions[dsLevel];
+ const dsPixSize2 = datasetResolutions[dsLevel + 1];
+
+ if (dsPixSize1 <= dsPixSize && dsPixSize <= dsPixSize2) {
+ const r = (dsPixSize - dsPixSize1) / (dsPixSize2 - dsPixSize1);
+ if (r < 0.5) {
+ return dsLevel;
+ } else {
+ return dsLevel + 1;
+ }
+ }
+ }
+ } else {
+ return undefined;
+ }
+}
diff --git a/src/reducers/controlReducer.ts b/src/reducers/controlReducer.ts
index 071a10df..a3d10e56 100644
--- a/src/reducers/controlReducer.ts
+++ b/src/reducers/controlReducer.ts
@@ -23,6 +23,8 @@ import {
SELECT_TIME_SERIES_UPDATE_MODE,
SELECT_VARIABLE,
SELECT_VARIABLE_2,
+ SET_DATASET_Z_LEVEL,
+ SET_LAYER_GROUP_STATES,
SET_LAYER_MENU_OPEN,
SET_LAYER_VISIBILITIES,
SET_MAP_INTERACTION,
@@ -30,18 +32,18 @@ import {
SET_SIDE_PANEL_ID,
SET_SIDE_PANEL_OPEN,
SET_VARIABLE_COMPARE_MODE,
- UPDATE_VARIABLE_SPLIT_POS,
SET_VISIBLE_INFO_CARD_ELEMENTS,
SET_VOLUME_RENDER_MODE,
+ SET_ZOOM_LEVEL,
STORE_SETTINGS,
+ TOGGLE_DATASET_RGB_LAYER,
UPDATE_INFO_CARD_ELEMENT_VIEW_MODE,
UPDATE_SETTINGS,
UPDATE_SIDE_PANEL_SIZE,
UPDATE_TIME_ANIMATION,
UPDATE_USER_COLOR_BAR,
+ UPDATE_VARIABLE_SPLIT_POS,
UPDATE_VOLUME_STATE,
- SET_LAYER_GROUP_STATES,
- TOGGLE_DATASET_RGB_LAYER,
} from "@/actions/controlActions";
import {
ADD_DRAWN_USER_PLACE,
@@ -570,6 +572,18 @@ export function controlReducer(
// swipe handle stays the same
};
}
+ case SET_ZOOM_LEVEL: {
+ return {
+ ...state,
+ zoomLevel: action.zoomLevel,
+ };
+ }
+ case SET_DATASET_Z_LEVEL: {
+ return {
+ ...state,
+ datasetZLevel: action.datasetZLevel,
+ };
+ }
case CONFIGURE_SERVERS: {
if (state.selectedServerId !== action.selectedServerId) {
return { ...state, selectedServerId: action.selectedServerId };
diff --git a/src/resources/config.json b/src/resources/config.json
index e9042d5a..f059b8b7 100644
--- a/src/resources/config.json
+++ b/src/resources/config.json
@@ -32,6 +32,7 @@
"allowUserVariables": true,
"allowViewModePython": true,
"allow3D": true,
- "permalinkExpirationDays": 120
+ "permalinkExpirationDays": 120,
+ "showZoomInfoBox": true
}
}
diff --git a/src/resources/lang.json b/src/resources/lang.json
index e7f9855a..d761f497 100644
--- a/src/resources/lang.json
+++ b/src/resources/lang.json
@@ -1381,6 +1381,16 @@
"de": "Über ${appName}",
"se": "Om ${appName}"
},
+ {
+ "en": "Levels",
+ "de": "Levels",
+ "se": "Levels"
+ },
+ {
+ "en": "Show zoom level indicator",
+ "de": "Zoomstufen anzeigen",
+ "se": "Visa zoomnivåer"
+ },
{
"en": "More",
"de": "Weiteres",
diff --git a/src/selectors/controlSelectors.tsx b/src/selectors/controlSelectors.tsx
index 649efbb3..f787935a 100644
--- a/src/selectors/controlSelectors.tsx
+++ b/src/selectors/controlSelectors.tsx
@@ -32,6 +32,7 @@ import {
Dataset,
findDataset,
findDatasetVariable,
+ getDatasetLevel,
getDatasetTimeDimension,
getDatasetTimeRange,
getDatasetUserVariables,
@@ -152,6 +153,10 @@ export const userColorBarsSelector = (state: AppState) =>
state.controlState.userColorBars;
export const userVariablesAllowedSelector = (_state: AppState) =>
Config.instance.branding.allowUserVariables;
+export const zoomLevelSelector = (state: AppState) =>
+ state.controlState.zoomLevel;
+export const selectedDatasetZLevelSelector = (state: AppState) =>
+ state.controlState.datasetZLevel;
const variableLayerIdSelector = () => "variable";
const variable2LayerIdSelector = () => "variable2";
@@ -202,6 +207,32 @@ export const selectedUserVariablesSelector = createSelector(
},
);
+export const getDatasetResolutions = (dataset: Dataset | null): number[] =>
+ dataset && dataset.resolutions ? dataset.resolutions : [];
+
+export const selectedDatasetResolutionsSelector = createSelector(
+ selectedDatasetSelector,
+ getDatasetResolutions,
+);
+
+export const getDatasetSpatialUnits = (
+ dataset: Dataset | null,
+): string | null =>
+ dataset && dataset.spatialUnits ? dataset.spatialUnits : null;
+
+export const selectedDatasetSpatialUnitsSelector = createSelector(
+ selectedDatasetSelector,
+ getDatasetSpatialUnits,
+);
+
+export const selectedDatasetLevelSelector = createSelector(
+ selectedDatasetResolutionsSelector,
+ selectedDatasetSpatialUnitsSelector,
+ selectedDatasetZLevelSelector,
+ mapProjectionSelector,
+ getDatasetLevel,
+);
+
const _findDatasetVariable = (
dataset: Dataset | null,
varName: string | null,
@@ -830,10 +861,10 @@ function getOlXYZSource(
// minZoom: tileLevelMin,
maxZoom: tileLevelMax,
crossOrigin: "Anonymous",
- //crossOrigin is set to "Anonymous", for allowing
- //to copy image on clipboard as we have custom tiles. If the source is
- //not set to anonymous it will give the CORS error and image will not be copied.
- //Source link: https://openlayers.org/en/latest/examples/wms-custom-proj.html
+ // crossOrigin is set to "Anonymous", for allowing
+ // to copy image on clipboard as we have custom tiles. If the source is
+ // not set to anonymous it will give the CORS error and image will not be copied.
+ // Source link: https://openlayers.org/en/latest/examples/wms-custom-proj.html
});
}
diff --git a/src/states/controlState.ts b/src/states/controlState.ts
index 86e86441..aada019d 100644
--- a/src/states/controlState.ts
+++ b/src/states/controlState.ts
@@ -136,6 +136,9 @@ export interface ControlState {
exportFileName: string;
themeMode: ThemeMode;
exportResolution: ExportResolution;
+ showZoomInfoBox: boolean;
+ zoomLevel: number | undefined;
+ datasetZLevel: number | undefined;
}
export function newControlState(): ControlState {
@@ -217,6 +220,9 @@ export function newControlState(): ControlState {
exportFileName: "export",
themeMode: getInitialThemeMode(),
exportResolution: 300,
+ showZoomInfoBox: branding.showZoomInfoBox || false,
+ zoomLevel: undefined,
+ datasetZLevel: undefined,
};
return loadUserSettings(state);
}
diff --git a/src/states/userSettings.ts b/src/states/userSettings.ts
index ba9784c2..00666122 100644
--- a/src/states/userSettings.ts
+++ b/src/states/userSettings.ts
@@ -96,6 +96,7 @@ export function storeUserSettings(settings: ControlState) {
storage.setObjectProperty("userPlacesFormatOptions", settings);
storage.setPrimitiveProperty("themeMode", settings);
storage.setPrimitiveProperty("exportResolution", settings);
+ storage.setPrimitiveProperty("showZoomInfoBox", settings);
if (import.meta.env.DEV) {
console.debug("Stored user settings:", settings);
}
diff --git a/src/util/branding.ts b/src/util/branding.ts
index 5c48567f..98df8845 100644
--- a/src/util/branding.ts
+++ b/src/util/branding.ts
@@ -91,6 +91,7 @@ export interface Branding {
allowViewModePython?: boolean;
allow3D?: boolean;
permalinkExpirationDays?: number;
+ showZoomInfoBox?: boolean;
}
function setBrandingPaletteColor(