diff --git a/docs/changelog/changelog-de.md b/docs/changelog/changelog-de.md index 778ad5d6..c7b10032 100644 --- a/docs/changelog/changelog-de.md +++ b/docs/changelog/changelog-de.md @@ -18,6 +18,7 @@ SPDX-License-Identifier: CC-BY-4.0 - Nutzer können benutzerdefinierte Schwellenwerte für aktuell ausgewählte Bezirk und Kompartiment festlegen, die über den Einstellungsbutton in der unteren linken Ecke des Liniendiagramms erreicht werden - Die horizontale Schwellenlinie wird als rote Linie im Diagramm angezeigt und Werte über der Schwellenlinie werden in rot angezeigt - Nutzer können Schwellenwerte auswählen, um zu den entsprechenden Bezirk und Kompartiment zu navigieren +- Nutzer können die Skalierung der Y-Achse manuell verändern (Maximalwert einstellen). ### Verbesserungen diff --git a/docs/changelog/changelog-en.md b/docs/changelog/changelog-en.md index c0493277..c429c800 100644 --- a/docs/changelog/changelog-en.md +++ b/docs/changelog/changelog-en.md @@ -18,6 +18,7 @@ SPDX-License-Identifier: CC-BY-4.0 - Users can set custom threshold values for currently selected district and compartment accessible via the settings button on the lower left corner of the line chart - The horizontal threshold is displayed as a red horizontal line on the chart and values above the threshold are displayed in red - Users can select thresholds to navigate to the corresponding district and compartment +- Users can now change the scaling of the Y-Axis by setting a maximum value. ### Improvements diff --git a/locales/de-settings.json5 b/locales/de-settings.json5 index 080b5a26..066796d6 100644 --- a/locales/de-settings.json5 +++ b/locales/de-settings.json5 @@ -3,6 +3,8 @@ { title: 'Liniendiagramm Einstellungen', + yAxisMaxValue: 'Y-Achse Maximum', + yAxisMaxValueDescription: 'Setzen Sie den Maximalwert für die Y-Achse', manageGroups: 'Filter', manageGroupsDescription: 'Verwalten Sie Filtergruppen, um spezifische Daten im Diagramm anzuzeigen oder auszublenden.', manageThreshold: 'Schwellwerte', @@ -14,6 +16,18 @@ threshold: 'Schwellwert', noThresholds: 'Keine Schwellwerte festgelegt.', }, + 'y-axis-settings': { + title: 'Y-Achse Maximum', + editTooltip: 'Y-Achse Maximum bearbeiten', + resetTooltip: 'Y-Achse Maximum auf aktuellen Daten-Maximalwert zurücksetzen', + messages: { + success: 'Y-Achse Maximum erfolgreich aktualisiert.', + error: 'Ungültige Eingabe. Bitte überprüfen Sie den Wert.', + }, + validation: { + positiveNumber: 'Der Wert muss eine positive Zahl sein', + }, + }, 'group-filters': { title: 'Gruppen verwalten', 'nothing-selected': 'Wählen Sie eine Gruppe aus um diese zu bearbeiten oder erstellen Sie eine neue Gruppe.', diff --git a/locales/en-settings.json5 b/locales/en-settings.json5 index fd06d085..aede3f65 100644 --- a/locales/en-settings.json5 +++ b/locales/en-settings.json5 @@ -3,6 +3,8 @@ { title: 'Line Chart Settings', + yAxisMaxValue: 'Y-Axis Maximum', + yAxisMaxValueDescription: 'Set the maximum value for the Y-axis', manageGroups: 'Filters', manageGroupsDescription: 'Set the group filters for each scenario', manageThreshold: 'Thresholds', @@ -14,6 +16,18 @@ threshold: 'Threshold', noThresholds: 'No thresholds set.', }, + 'y-axis-settings': { + title: 'Y-Axis Maximum', + editTooltip: 'Edit Y-Axis Maximum', + resetTooltip: 'Reset Y-Axis maximum value to current data maximum value', + messages: { + success: 'Y-Axis Maximum value updated successfully.', + error: 'Invalid input. Please check the value.', + }, + validation: { + positiveNumber: 'Value must be a positive number', + }, + }, 'group-filters': { title: 'Manage Groups', 'nothing-selected': 'Select a group to edit or create a new one.', diff --git a/src/components/LineChartComponents/LineChart.tsx b/src/components/LineChartComponents/LineChart.tsx index a737afe9..23907b0c 100644 --- a/src/components/LineChartComponents/LineChart.tsx +++ b/src/components/LineChartComponents/LineChart.tsx @@ -70,6 +70,9 @@ interface LineChartProps { /** Optional horizontal limit for the Y-axis. Defaults to 0. */ horizontalYAxisThreshold?: number; + + /** Optional maximum value from the chart data. Can be used to set custom Y-axis limits. */ + yAxisMaxValue?: number; } /** * React Component to render the Linechart Section @@ -88,6 +91,7 @@ export default function LineChart({ yAxisLabel, localization, horizontalYAxisThreshold = undefined, + yAxisMaxValue, }: LineChartProps): JSX.Element { const {t: defaultT, i18n} = useTranslation(); @@ -165,11 +169,13 @@ export default function LineChart({ ); const yAxisSettings = useMemo(() => { - if (!root || !chart) { + if (!root || !chart || chart.isDisposed() || root.isDisposed()) { return null; } return { renderer: AxisRendererY.new(root, {}), + strictMinMax: true, + // Fix lower end to 0 min: 0, // Add tooltip instance so cursor can display value @@ -179,6 +185,17 @@ export default function LineChart({ const yAxis = useValueAxis(root, chart, yAxisSettings); + // max value y-axis change so that its not recreated everytime max value changes + useLayoutEffect(() => { + if (!yAxis || !root || !chart || chart.isDisposed() || root.isDisposed()) return; + + if (yAxisMaxValue != null) { + yAxis.set('max', yAxisMaxValue); + } else { + yAxis.set('max', undefined); + } + }, [yAxis, yAxisMaxValue, root, chart]); + // Effect to add cursor to chart useLayoutEffect(() => { if (!chart || !root || !xAxis || chart.isDisposed() || root.isDisposed() || xAxis.isDisposed()) { diff --git a/src/components/LineChartComponents/LineChartSettingsComponents/LineChartSettings.tsx b/src/components/LineChartComponents/LineChartSettingsComponents/LineChartSettings.tsx index 95df8bf6..655a0267 100644 --- a/src/components/LineChartComponents/LineChartSettingsComponents/LineChartSettings.tsx +++ b/src/components/LineChartComponents/LineChartSettingsComponents/LineChartSettings.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) // SPDX-License-Identifier: Apache-2.0 -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import SettingsIcon from '@mui/icons-material/Settings'; import Popover from '@mui/material/Popover'; import Typography from '@mui/material/Typography'; @@ -12,25 +12,38 @@ import IconButton from '@mui/material/IconButton'; import CloseIcon from '@mui/icons-material/Close'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import DataThresholdingIcon from '@mui/icons-material/DataThresholdingRounded'; +import ShowChartIcon from '@mui/icons-material/ShowChart'; import type {Threshold} from 'types/threshold'; import type {District} from 'types/district'; import {useTranslation} from 'react-i18next'; import ThresholdSettings from './ThresholdSettings/ThresholdSettings'; +import YAxisValueSettings from './yAxisValueSettings/yAxisValueSettings'; +import {NumberFormatter} from 'util/hooks'; /** * The different views that can be displayed in the settings popover. * You can add more views here if you want to add more settings. */ -type SettingsView = 'settingsMenu' | 'thresholdSettings' | 'filters'; - -type SettingsMenu = { - [key: string]: { - label: string; - description: string; - view: string; - icon: JSX.Element; - }; +type SettingsView = 'settingsMenu' | 'thresholdSettings' | 'yAxisMaxValueSettings'; + +type NavigationItem = { + kind: 'navigate'; + label?: string; + description: string; + icon: JSX.Element; + view: SettingsView; // 'horizontalThresholdSettings' | ... +}; + +type InlineItem = { + kind: 'inline'; + label?: string; + description?: string; + icon?: JSX.Element; + element: JSX.Element; // the component you want to show inline }; + +type SettingsItem = NavigationItem | InlineItem; + export interface LineChartSettingsProps { /** The district to which the settings apply. */ selectedDistrict: District; @@ -49,6 +62,15 @@ export interface LineChartSettingsProps { /** The function to update a horizontal threshold. */ updateThreshold: (newThreshold: Threshold) => void; + + /** The maximum value for the y-axis. */ + yAxisMaxValue: number | undefined; + + /** The maximum value for the data. */ + maxDataValue: number; + + /** The function to update the maximum value for the y-axis. */ + updateYAxisMaxValue: (newYAxisMaxValue: number | undefined) => void; } /** @@ -63,34 +85,54 @@ export default function LineChartSettings({ thresholds, removeThreshold, updateThreshold, + yAxisMaxValue, + maxDataValue, + updateYAxisMaxValue, }: LineChartSettingsProps) { const {t: tSettings} = useTranslation('settings'); + const {i18n} = useTranslation(); + const {formatNumber} = NumberFormatter(i18n.language, 1, 0); + + const [currentView, setCurrentView] = useState('settingsMenu'); + const [anchorEl, setAnchorEl] = useState(null); + const [showPopover, setShowPopover] = useState(false); + + const localization = useMemo(() => { + return { + formatNumber: formatNumber, + customLang: 'backend', + }; + }, [formatNumber]); /** * The settings menu for the line chart. Each item in the menu has a label, a view, and an icon. + * kind: 'inline' - the item is displayed inline + * kind: 'navigate' - the item is displayed as a button that navigates to a new view */ - - const settingsMenu: SettingsMenu = { - threshold: { + const settingsMenu: SettingsItem[] = [ + { + kind: 'inline', + label: tSettings('yAxisMaxValue'), + element: ( + + + + + ), + }, + { + kind: 'navigate', label: tSettings('manageThreshold'), description: tSettings('manageThresholdDescription'), + icon: , view: 'thresholdSettings', - icon: ( - - ), }, - // filters: { - // label: tSettings('manageGroups'), - // view: 'filters', - // icon: , - // }, - }; - - const [currentView, setCurrentView] = useState('settingsMenu'); - const [anchorEl, setAnchorEl] = useState(null); - const [showPopover, setShowPopover] = useState(false); + ]; const handlePopoverOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -162,36 +204,33 @@ export default function LineChartSettings({ {currentView === 'settingsMenu' && ( {renderHeader(tSettings('title'))} - {Object.entries(settingsMenu).map(([key, item]) => ( - - - + {item.icon} + + {item.label} + + {item.description} + + + + ) : ( + {item.element} + )} ))} diff --git a/src/components/LineChartComponents/LineChartSettingsComponents/yAxisValueSettings/yAxisValueSettings.tsx b/src/components/LineChartComponents/LineChartSettingsComponents/yAxisValueSettings/yAxisValueSettings.tsx new file mode 100644 index 00000000..d3846da5 --- /dev/null +++ b/src/components/LineChartComponents/LineChartSettingsComponents/yAxisValueSettings/yAxisValueSettings.tsx @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) +// SPDX-License-Identifier: Apache-2.0 + +import React, {useState} from 'react'; +import Box from '@mui/material/Box'; +import {Localization} from 'types/localization'; +import {Button, useTheme} from '@mui/material'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Snackbar from '@mui/material/Snackbar'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; +import CheckIcon from '@mui/icons-material/Check'; +import CancelIcon from '@mui/icons-material/Cancel'; +import EditIcon from '@mui/icons-material/Edit'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import Divider from '@mui/material/Divider'; +import Portal from '@mui/material/Portal'; +import Alert from '@mui/material/Alert'; +import {useTranslation} from 'react-i18next'; +interface YAxisValueSettingsProps { + /** The maximum value for the Y-axis */ + yAxisMaxValue: number | undefined; + + /** The actual maximum value of the currently displayed data on the chart*/ + maxDataValue: number; + + /** The function to update the Y-axis maximum value */ + updateYAxisMaxValue: (newYAxisMaxValue: number | undefined) => void; + + /** The localization object */ + localization: Localization; +} + +export default function YAxisValueSettings({ + yAxisMaxValue, + maxDataValue, + updateYAxisMaxValue, + localization = { + formatNumber: (value: number) => value.toString(), + customLang: 'global', + overrides: {}, + }, +}: YAxisValueSettingsProps) { + const theme = useTheme(); + const {t} = useTranslation('settings'); + const [localYAxisMaxValue, setLocalYAxisMaxValue] = useState(yAxisMaxValue ?? maxDataValue); + const [editing, setEditing] = useState(false); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(''); + const [snackbarSeverity, setSnackbarSeverity] = useState<'success' | 'info' | 'warning' | 'error'>('info'); + const [errors, setErrors] = useState([]); + + const getFormattedAndTranslatedValues = (filteredValues: number): string => { + return localization.formatNumber ? localization.formatNumber(filteredValues) : filteredValues.toString(); + }; + + const validateInput = (newYValue: number | null): boolean => { + const errors: string[] = []; + if (newYValue === null || newYValue < 0) { + errors.push(t('y-axis-settings.validation.positiveNumber')); + } + + setErrors(errors); + return errors.length === 0; + }; + + const handleSnackbarOpen = (message: string, severity: 'success' | 'info' | 'warning' | 'error' = 'info') => { + setSnackbarMessage(message); + setSnackbarSeverity(severity); + setSnackbarOpen(true); + }; + + const handleSave = (newYValue: number | null) => { + if (newYValue === null) { + updateYAxisMaxValue(maxDataValue); + setLocalYAxisMaxValue(maxDataValue); + setEditing(false); + return; + } + + if (validateInput(newYValue)) { + if (newYValue === yAxisMaxValue) { + setEditing(false); + return; + } + + updateYAxisMaxValue(newYValue); + setLocalYAxisMaxValue(newYValue); + setEditing(false); + handleSnackbarOpen(t('y-axis-settings.messages.success'), 'success'); + } + }; + + const handleCancel = () => { + setEditing(false); + setLocalYAxisMaxValue(yAxisMaxValue ?? maxDataValue); + }; + + const handleReset = () => { + updateYAxisMaxValue(undefined); + setLocalYAxisMaxValue(maxDataValue); + }; + + return ( + + + setSnackbarOpen(false)} + anchorOrigin={{vertical: 'bottom', horizontal: 'left'}} + > + {snackbarMessage} + + + + + {t('y-axis-settings.title')} + + + + + {editing ? ( + + 0} + helperText={errors.join(', ')} + onChange={(e) => { + setErrors([]); + const value = e.target.value === '' ? null : Number(e.target.value); + setLocalYAxisMaxValue(value); + }} + size='small' + variant='outlined' + sx={{width: '100%'}} + /> + + { + e.stopPropagation(); + handleSave(localYAxisMaxValue); + }} + disabled={errors.length > 0} + sx={{color: theme.palette.success.main}} + > + + + { + e.stopPropagation(); + handleCancel(); + }} + sx={{color: theme.palette.error.main}} + > + + + + + ) : ( + + + + + setEditing(true)}> + + + + + { + e.stopPropagation(); + handleReset(); + }} + disabled={localYAxisMaxValue === maxDataValue} + > + + + + + + )} + + + ); +} diff --git a/src/components/LineChartContainer.tsx b/src/components/LineChartContainer.tsx index f9420bd6..ca144ed1 100644 --- a/src/components/LineChartContainer.tsx +++ b/src/components/LineChartContainer.tsx @@ -13,8 +13,11 @@ import {useTranslation} from 'react-i18next'; import {LineChartData} from 'types/lineChart'; import {InfectionData} from 'store/services/APITypes'; import {DataContext} from 'context/SelectedDataContext'; -import {updateHorizontalYAxisThreshold, removeHorizontalYAxisThreshold} from 'store/UserPreferenceSlice'; - +import { + updateHorizontalYAxisThreshold, + removeHorizontalYAxisThreshold, + setYAxisMaxValue, +} from 'store/UserPreferenceSlice'; export default function LineChartContainer() { const {t: tBackend, i18n: i18nBackend} = useTranslation('backend'); const theme = useTheme(); @@ -28,6 +31,7 @@ export default function LineChartContainer() { const selectedDistrict = useAppSelector((state) => state.dataSelection.district); const selectedDate = useAppSelector((state) => state.dataSelection.date); const thresholds = useAppSelector((state) => state.userPreference.horizontalYAxisThresholds ?? {}); + const yAxisMaxValue = useAppSelector((state) => state.userPreference.yAxisMaxValue ?? undefined); const referenceDay = useAppSelector((state) => state.dataSelection.simulationStart); const minDate = useAppSelector((state) => state.dataSelection.minDate); const maxDate = useAppSelector((state) => state.dataSelection.maxDate); @@ -153,6 +157,31 @@ export default function LineChartContainer() { }); }, [groupFilterLineChartData, groupFilters, lineChartData, scenarios, scenariosState, selectedScenario]); + // Calculate maximum value from chart data + const maxDataValue = useMemo(() => { + if (!mappedLineChartData || mappedLineChartData.length === 0) { + return 0; + } + + let maxValue = 0; + + mappedLineChartData.forEach((serie) => { + serie.values.forEach((entry) => { + // Check main value + if (entry.value > maxValue) { + maxValue = entry.value; + } + + // Check openValue if it exists (for percentile bands) + if (entry.openValue && entry.openValue > maxValue) { + maxValue = entry.openValue; + } + }); + }); + + return Math.ceil(maxValue); + }, [mappedLineChartData]); + // Set reference day in store useEffect(() => { dispatch(setReferenceDayBottom(referenceDayBottomPosition)); @@ -175,7 +204,9 @@ export default function LineChartContainer() { referenceDay={referenceDay} yAxisLabel={yAxisLabel} horizontalYAxisThreshold={thresholds[`${selectedDistrict.nuts}-${selectedCompartment}`]?.threshold} + yAxisMaxValue={yAxisMaxValue ?? maxDataValue} /> + dispatch(setYAxisMaxValue(newYAxisMaxValue))} /> ); diff --git a/src/store/UserPreferenceSlice.ts b/src/store/UserPreferenceSlice.ts index 98d9ca49..00e908fb 100644 --- a/src/store/UserPreferenceSlice.ts +++ b/src/store/UserPreferenceSlice.ts @@ -10,6 +10,7 @@ export interface UserPreference { selectedTab?: string; isInitialVisit: boolean; horizontalYAxisThresholds?: Record; + yAxisMaxValue?: number | undefined; scenarioColors: Record; } @@ -26,6 +27,7 @@ const initialState: UserPreference = { selectedTab: '1', isInitialVisit: true, horizontalYAxisThresholds: {}, + yAxisMaxValue: undefined, scenarioColors: {}, }; @@ -81,6 +83,10 @@ export const UserPreferenceSlice = createSlice({ } delete state.horizontalYAxisThresholds[action.payload]; }, + /** Set the maximum value for the Y-axis */ + setYAxisMaxValue(state, action: PayloadAction) { + state.yAxisMaxValue = action.payload; + }, }, }); @@ -92,6 +98,7 @@ export const { updateHorizontalYAxisThreshold, removeHorizontalYAxisThreshold, setScenarioColors, + setYAxisMaxValue, } = UserPreferenceSlice.actions; export default UserPreferenceSlice.reducer;