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 + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Zoom Infobox +
Feature NameZoom 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. +
AimEnable 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 ( handleMapClick(event)} + onZoom={(event, map) => handleMapZoom(event, map)} onMapRef={handleMapRef} mapObjects={MAP_OBJECTS} isStale={true} @@ -431,6 +469,7 @@ export default function Viewer({ {mapPointInfoBox} {mapControlActions} {mapSplitter} + {zoomBox} 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(