From 3e1a4071b94d976ef75b63fbe201f2881fc401cc Mon Sep 17 00:00:00 2001 From: donia benharara Date: Fri, 22 Nov 2024 19:17:11 +0100 Subject: [PATCH] Divided AA into drought and storm layers and panels --- .../components/MapView/DateSelector/index.tsx | 24 +- .../AnticipatoryActionDrougthLayer/index.tsx | 231 ++++++++++++ .../Layers/AnticipatoryActionLayer/index.tsx | 229 ------------ .../AnticipatoryActionStormLayer/index.tsx | 231 ++++++++++++ .../src/components/MapView/Layers/index.ts | 6 +- .../MapView/LeftPanel/AnalysisPanel/index.tsx | 2 +- .../AnticipatoryActionDroughtPanel/index.tsx | 345 +++++++++++++++++ .../AnticipatoryActionStormPanel/index.tsx | 345 +++++++++++++++++ .../DistrictView/index.test.tsx | 2 +- .../Forecast/index.test.tsx | 2 +- .../HomeTable/index.test.tsx | 2 +- .../Timeline/index.test.tsx | 2 +- .../AnticipatoryActionPanel/index.tsx | 347 +----------------- .../MapView/LeftPanel/ChartsPanel/index.tsx | 3 +- .../MapView/LeftPanel/TablesPanel/index.tsx | 3 +- .../components/MapView/LeftPanel/index.tsx | 65 +++- .../SwitchItem/ExposureAnalysisOption.tsx | 3 +- .../MapView/Legends/LegendItemsList.tsx | 7 +- frontend/src/components/MapView/Map/index.tsx | 19 +- frontend/src/components/MapView/utils.ts | 4 +- .../components/NavBar/PanelButton/index.tsx | 78 ++++ .../src/components/NavBar/PanelMenu/index.tsx | 43 +++ .../NavBar/PrintImage/index.test.tsx | 2 +- .../NavBar/PrintImage/printPreview.tsx | 6 +- frontend/src/components/NavBar/index.tsx | 148 ++++---- frontend/src/config/types.ts | 31 +- frontend/src/config/utils.ts | 53 ++- frontend/src/context/leftPanelStateSlice.ts | 17 +- frontend/src/context/mapStateSlice/index.ts | 6 +- frontend/src/utils/keep-layer-utils.ts | 3 +- frontend/src/utils/layers-utils.tsx | 27 +- frontend/src/utils/server-utils.ts | 3 +- 32 files changed, 1547 insertions(+), 742 deletions(-) create mode 100644 frontend/src/components/MapView/Layers/AnticipatoryActionDrougthLayer/index.tsx delete mode 100644 frontend/src/components/MapView/Layers/AnticipatoryActionLayer/index.tsx create mode 100644 frontend/src/components/MapView/Layers/AnticipatoryActionStormLayer/index.tsx create mode 100644 frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionDroughtPanel/index.tsx create mode 100644 frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionStormPanel/index.tsx create mode 100644 frontend/src/components/NavBar/PanelButton/index.tsx create mode 100644 frontend/src/components/NavBar/PanelMenu/index.tsx diff --git a/frontend/src/components/MapView/DateSelector/index.tsx b/frontend/src/components/MapView/DateSelector/index.tsx index 3ef66dd32..415a11778 100644 --- a/frontend/src/components/MapView/DateSelector/index.tsx +++ b/frontend/src/components/MapView/DateSelector/index.tsx @@ -14,7 +14,12 @@ import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; import Draggable, { DraggableEvent } from 'react-draggable'; import { useDispatch, useSelector } from 'react-redux'; -import { DateItem, DateRangeType } from 'config/types'; +import { + AnticipatoryAction, + DateItem, + DateRangeType, + Panel, +} from 'config/types'; import { dateRangeSelector } from 'context/mapStateSlice/selectors'; import { locales, useSafeTranslation } from 'i18n'; import { @@ -26,10 +31,11 @@ import { DateFormat } from 'utils/name-utils'; import { useUrlHistory } from 'utils/url-utils'; import useLayers from 'utils/layers-utils'; import { format } from 'date-fns'; -import { Panel, leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; +import { leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; import { updateDateRange } from 'context/mapStateSlice'; import { getRequestDate } from 'utils/server-utils'; import { AAAvailableDatesSelector } from 'context/anticipatoryActionStateSlice'; +import { isAnticipatoryActionLayer } from 'config/utils'; import TickSvg from './tick.svg'; import DateSelectorInput from './DateSelectorInput'; import TimelineItems from './TimelineItems'; @@ -51,8 +57,10 @@ const POINTER_ID = 'datePointerSelector'; const calculateStartAndEndDates = (startDate: Date, selectedTab: string) => { const year = startDate.getFullYear() - - (selectedTab === 'anticipatory_action' && startDate.getMonth() < 3 ? 1 : 0); - const startMonth = selectedTab === 'anticipatory_action' ? 3 : 0; // April for anticipatory_action, January otherwise + (isAnticipatoryActionLayer(selectedTab) && startDate.getMonth() < 3 + ? 1 + : 0); + const startMonth = isAnticipatoryActionLayer(selectedTab) ? 3 : 0; // April for anticipatory_action, January otherwise const start = new Date(year, startMonth, 1); const end = new Date(year, startMonth + 11, 31); @@ -123,14 +131,14 @@ const DateSelector = memo(() => { id: 'anticipatory_action_window_1', title: 'Window 1', dateItems: AAAvailableDates['Window 1'], - type: 'anticipatory_action', + type: AnticipatoryAction.drought, opacity: 1, }, { id: 'anticipatory_action_window_2', title: 'Window 2', dateItems: AAAvailableDates['Window 2'], - type: 'anticipatory_action', + type: AnticipatoryAction.drought, opacity: 1, }, ] @@ -155,7 +163,7 @@ const DateSelector = memo(() => { } return 0; }) - .map(l => (l.type === 'anticipatory_action' ? AALayers : l)) + .map(l => (isAnticipatoryActionLayer(l.type) ? AALayers : l)) .flat(), [selectedLayers, AALayers], ); @@ -330,7 +338,7 @@ const DateSelector = memo(() => { ); // All dates in AA windows should be selectable, regardless of overlap - if (panelTab === Panel.AnticipatoryAction && AAAvailableDates) { + if (panelTab === Panel.AnticipatoryActionDrought && AAAvailableDates) { // eslint-disable-next-line fp/no-mutating-methods dates.push( AAAvailableDates?.['Window 1']?.map(d => d.displayDate) ?? [], diff --git a/frontend/src/components/MapView/Layers/AnticipatoryActionDrougthLayer/index.tsx b/frontend/src/components/MapView/Layers/AnticipatoryActionDrougthLayer/index.tsx new file mode 100644 index 000000000..c79414f27 --- /dev/null +++ b/frontend/src/components/MapView/Layers/AnticipatoryActionDrougthLayer/index.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { + AdminLevelDataLayerProps, + AnticipatoryActionLayerProps, + BoundaryLayerProps, + MapEventWrapFunctionProps, +} from 'config/types'; +import { useDefaultDate } from 'utils/useDefaultDate'; +import { useDispatch, useSelector } from 'react-redux'; +import { + layerDataSelector, + mapSelector, +} from 'context/mapStateSlice/selectors'; +import { LayerData } from 'context/layers/layer-data'; +import { + Layer, + MapLayerMouseEvent, + Marker, + Source, +} from 'react-map-gl/maplibre'; +import { + AAFiltersSelector, + AAMarkersSelector, + AARenderedDistrictsSelector, + AASelectedDistrictSelector, + setAAMarkers, + setAASelectedDistrict, + setAAView, +} from 'context/anticipatoryActionStateSlice'; +import { getAAColor } from 'components/MapView/LeftPanel/AnticipatoryActionPanel/utils'; +import { + calculateCentroids, + useAAMarkerScalePercent, + useMapCallback, +} from 'utils/map-utils'; +import { getBoundaryLayersByAdminLevel } from 'config/utils'; +import { + calculateAAMarkers, + calculateCombinedAAMapData, +} from 'context/anticipatoryActionStateSlice/utils'; +import { AAView } from 'context/anticipatoryActionStateSlice/types'; +import { Tooltip } from '@material-ui/core'; + +// Use admin level 2 boundary layer for Anticipatory Action +const boundaryLayer = getBoundaryLayersByAdminLevel(2); + +const onDistrictClick = + ({ dispatch }: MapEventWrapFunctionProps) => + (evt: MapLayerMouseEvent) => { + const districtId = + evt.features?.[0]?.properties?.[boundaryLayer.adminLevelLocalNames[1]]; + if (districtId) { + dispatch(setAASelectedDistrict(districtId)); + dispatch(setAAView(AAView.District)); + } + }; + +const AnticipatoryActionDrougthLayer = React.memo( + ({ layer, before }: LayersProps) => { + useDefaultDate(layer.id); + const boundaryLayerState = useSelector( + layerDataSelector(boundaryLayer.id), + ) as LayerData | undefined; + const { data } = boundaryLayerState || {}; + const map = useSelector(mapSelector); + const dispatch = useDispatch(); + const renderedDistricts = useSelector(AARenderedDistrictsSelector); + const { selectedWindow } = useSelector(AAFiltersSelector); + const selectedDistrict = useSelector(AASelectedDistrictSelector); + const markers = useSelector(AAMarkersSelector); + + useMapCallback( + 'click', + `anticipatory-action-fill`, + layer as any, + onDistrictClick, + ); + + const shouldRenderData = React.useMemo(() => { + if (selectedWindow === 'All') { + return calculateCombinedAAMapData(renderedDistricts); + } + if (selectedWindow) { + return Object.fromEntries( + Object.entries(renderedDistricts[selectedWindow]).map( + ([dist, values]) => [dist, values[0]], + ), + ); + } + return {}; + }, [renderedDistricts, selectedWindow]); + + // Calculate centroids only once per data change + React.useEffect(() => { + const districtCentroids = calculateCentroids(data); + const m = calculateAAMarkers({ + renderedDistricts, + selectedWindow, + districtCentroids, + }); + dispatch(setAAMarkers(m)); + }, [data, dispatch, renderedDistricts, selectedWindow]); + + const highlightDistrictLine = React.useMemo( + () => ({ + ...data, + features: [ + data?.features.find( + f => + f.properties?.[boundaryLayer.adminLevelLocalNames[1]] === + selectedDistrict, + ), + ].filter(x => x), + }), + [data, selectedDistrict], + ); + + const coloredDistrictsLayer = React.useMemo(() => { + const districtEntries = Object.entries(shouldRenderData); + if (!data || !districtEntries.length) { + return null; + } + return { + ...data, + features: Object.entries(shouldRenderData) + .map(([districtId, { category, phase }]: [string, any]) => { + const feature = data?.features.find( + f => + f.properties?.[boundaryLayer.adminLevelLocalNames[1]] === + districtId, + ); + + if (!feature) { + return null; + } + const color = getAAColor(category, phase, true); + return { + ...feature, + properties: { + ...feature.properties, + fillColor: color || 'grey', + }, + }; + }) + .filter(f => f !== null), + }; + }, [data, shouldRenderData]); + + const scalePercent = useAAMarkerScalePercent(map); + + const mainLayerBefore = selectedDistrict + ? 'anticipatory-action-selected-line' + : before; + + return ( + <> + {markers.map(marker => ( + + +
+ {marker.icon} +
+
+
+ ))} + + 0 ? 1 : 0, + }} + /> + + {coloredDistrictsLayer && ( + + + + + )} + + ); + }, +); +export interface LayersProps { + layer: AnticipatoryActionLayerProps; + before?: string; +} + +export default AnticipatoryActionDrougthLayer; diff --git a/frontend/src/components/MapView/Layers/AnticipatoryActionLayer/index.tsx b/frontend/src/components/MapView/Layers/AnticipatoryActionLayer/index.tsx deleted file mode 100644 index 49a84e997..000000000 --- a/frontend/src/components/MapView/Layers/AnticipatoryActionLayer/index.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import React from 'react'; -import { - AdminLevelDataLayerProps, - AnticipatoryActionLayerProps, - BoundaryLayerProps, - MapEventWrapFunctionProps, -} from 'config/types'; -import { useDefaultDate } from 'utils/useDefaultDate'; -import { useDispatch, useSelector } from 'react-redux'; -import { - layerDataSelector, - mapSelector, -} from 'context/mapStateSlice/selectors'; -import { LayerData } from 'context/layers/layer-data'; -import { - Layer, - MapLayerMouseEvent, - Marker, - Source, -} from 'react-map-gl/maplibre'; -import { - AAFiltersSelector, - AAMarkersSelector, - AARenderedDistrictsSelector, - AASelectedDistrictSelector, - setAAMarkers, - setAASelectedDistrict, - setAAView, -} from 'context/anticipatoryActionStateSlice'; -import { getAAColor } from 'components/MapView/LeftPanel/AnticipatoryActionPanel/utils'; -import { - calculateCentroids, - useAAMarkerScalePercent, - useMapCallback, -} from 'utils/map-utils'; -import { getBoundaryLayersByAdminLevel } from 'config/utils'; -import { - calculateAAMarkers, - calculateCombinedAAMapData, -} from 'context/anticipatoryActionStateSlice/utils'; -import { AAView } from 'context/anticipatoryActionStateSlice/types'; -import { Tooltip } from '@material-ui/core'; - -// Use admin level 2 boundary layer for Anticipatory Action -const boundaryLayer = getBoundaryLayersByAdminLevel(2); - -const onDistrictClick = - ({ dispatch }: MapEventWrapFunctionProps) => - (evt: MapLayerMouseEvent) => { - const districtId = - evt.features?.[0]?.properties?.[boundaryLayer.adminLevelLocalNames[1]]; - if (districtId) { - dispatch(setAASelectedDistrict(districtId)); - dispatch(setAAView(AAView.District)); - } - }; - -const AnticipatoryActionLayer = React.memo(({ layer, before }: LayersProps) => { - useDefaultDate(layer.id); - const boundaryLayerState = useSelector( - layerDataSelector(boundaryLayer.id), - ) as LayerData | undefined; - const { data } = boundaryLayerState || {}; - const map = useSelector(mapSelector); - const dispatch = useDispatch(); - const renderedDistricts = useSelector(AARenderedDistrictsSelector); - const { selectedWindow } = useSelector(AAFiltersSelector); - const selectedDistrict = useSelector(AASelectedDistrictSelector); - const markers = useSelector(AAMarkersSelector); - - useMapCallback( - 'click', - `anticipatory-action-fill`, - layer as any, - onDistrictClick, - ); - - const shouldRenderData = React.useMemo(() => { - if (selectedWindow === 'All') { - return calculateCombinedAAMapData(renderedDistricts); - } - if (selectedWindow) { - return Object.fromEntries( - Object.entries(renderedDistricts[selectedWindow]).map( - ([dist, values]) => [dist, values[0]], - ), - ); - } - return {}; - }, [renderedDistricts, selectedWindow]); - - // Calculate centroids only once per data change - React.useEffect(() => { - const districtCentroids = calculateCentroids(data); - const m = calculateAAMarkers({ - renderedDistricts, - selectedWindow, - districtCentroids, - }); - dispatch(setAAMarkers(m)); - }, [data, dispatch, renderedDistricts, selectedWindow]); - - const highlightDistrictLine = React.useMemo( - () => ({ - ...data, - features: [ - data?.features.find( - f => - f.properties?.[boundaryLayer.adminLevelLocalNames[1]] === - selectedDistrict, - ), - ].filter(x => x), - }), - [data, selectedDistrict], - ); - - const coloredDistrictsLayer = React.useMemo(() => { - const districtEntries = Object.entries(shouldRenderData); - if (!data || !districtEntries.length) { - return null; - } - return { - ...data, - features: Object.entries(shouldRenderData) - .map(([districtId, { category, phase }]: [string, any]) => { - const feature = data?.features.find( - f => - f.properties?.[boundaryLayer.adminLevelLocalNames[1]] === - districtId, - ); - - if (!feature) { - return null; - } - const color = getAAColor(category, phase, true); - return { - ...feature, - properties: { - ...feature.properties, - fillColor: color || 'grey', - }, - }; - }) - .filter(f => f !== null), - }; - }, [data, shouldRenderData]); - - const scalePercent = useAAMarkerScalePercent(map); - - const mainLayerBefore = selectedDistrict - ? 'anticipatory-action-selected-line' - : before; - - return ( - <> - {markers.map(marker => ( - - -
- {marker.icon} -
-
-
- ))} - - 0 ? 1 : 0, - }} - /> - - {coloredDistrictsLayer && ( - - - - - )} - - ); -}); -export interface LayersProps { - layer: AnticipatoryActionLayerProps; - before?: string; -} - -export default AnticipatoryActionLayer; diff --git a/frontend/src/components/MapView/Layers/AnticipatoryActionStormLayer/index.tsx b/frontend/src/components/MapView/Layers/AnticipatoryActionStormLayer/index.tsx new file mode 100644 index 000000000..9bb768528 --- /dev/null +++ b/frontend/src/components/MapView/Layers/AnticipatoryActionStormLayer/index.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { + AdminLevelDataLayerProps, + AnticipatoryActionLayerProps, + BoundaryLayerProps, + MapEventWrapFunctionProps, +} from 'config/types'; +import { useDefaultDate } from 'utils/useDefaultDate'; +import { useDispatch, useSelector } from 'react-redux'; +import { + layerDataSelector, + mapSelector, +} from 'context/mapStateSlice/selectors'; +import { LayerData } from 'context/layers/layer-data'; +import { + Layer, + MapLayerMouseEvent, + Marker, + Source, +} from 'react-map-gl/maplibre'; +import { + AAFiltersSelector, + AAMarkersSelector, + AARenderedDistrictsSelector, + AASelectedDistrictSelector, + setAAMarkers, + setAASelectedDistrict, + setAAView, +} from 'context/anticipatoryActionStateSlice'; +import { getAAColor } from 'components/MapView/LeftPanel/AnticipatoryActionPanel/utils'; +import { + calculateCentroids, + useAAMarkerScalePercent, + useMapCallback, +} from 'utils/map-utils'; +import { getBoundaryLayersByAdminLevel } from 'config/utils'; +import { + calculateAAMarkers, + calculateCombinedAAMapData, +} from 'context/anticipatoryActionStateSlice/utils'; +import { AAView } from 'context/anticipatoryActionStateSlice/types'; +import { Tooltip } from '@material-ui/core'; + +// Use admin level 2 boundary layer for Anticipatory Action +const boundaryLayer = getBoundaryLayersByAdminLevel(2); + +const onDistrictClick = + ({ dispatch }: MapEventWrapFunctionProps) => + (evt: MapLayerMouseEvent) => { + const districtId = + evt.features?.[0]?.properties?.[boundaryLayer.adminLevelLocalNames[1]]; + if (districtId) { + dispatch(setAASelectedDistrict(districtId)); + dispatch(setAAView(AAView.District)); + } + }; + +const AnticipatoryActionStormLayer = React.memo( + ({ layer, before }: LayersProps) => { + useDefaultDate(layer.id); + const boundaryLayerState = useSelector( + layerDataSelector(boundaryLayer.id), + ) as LayerData | undefined; + const { data } = boundaryLayerState || {}; + const map = useSelector(mapSelector); + const dispatch = useDispatch(); + const renderedDistricts = useSelector(AARenderedDistrictsSelector); + const { selectedWindow } = useSelector(AAFiltersSelector); + const selectedDistrict = useSelector(AASelectedDistrictSelector); + const markers = useSelector(AAMarkersSelector); + + useMapCallback( + 'click', + `anticipatory-action-fill`, + layer as any, + onDistrictClick, + ); + + const shouldRenderData = React.useMemo(() => { + if (selectedWindow === 'All') { + return calculateCombinedAAMapData(renderedDistricts); + } + if (selectedWindow) { + return Object.fromEntries( + Object.entries(renderedDistricts[selectedWindow]).map( + ([dist, values]) => [dist, values[0]], + ), + ); + } + return {}; + }, [renderedDistricts, selectedWindow]); + + // Calculate centroids only once per data change + React.useEffect(() => { + const districtCentroids = calculateCentroids(data); + const m = calculateAAMarkers({ + renderedDistricts, + selectedWindow, + districtCentroids, + }); + dispatch(setAAMarkers(m)); + }, [data, dispatch, renderedDistricts, selectedWindow]); + + const highlightDistrictLine = React.useMemo( + () => ({ + ...data, + features: [ + data?.features.find( + f => + f.properties?.[boundaryLayer.adminLevelLocalNames[1]] === + selectedDistrict, + ), + ].filter(x => x), + }), + [data, selectedDistrict], + ); + + const coloredDistrictsLayer = React.useMemo(() => { + const districtEntries = Object.entries(shouldRenderData); + if (!data || !districtEntries.length) { + return null; + } + return { + ...data, + features: Object.entries(shouldRenderData) + .map(([districtId, { category, phase }]: [string, any]) => { + const feature = data?.features.find( + f => + f.properties?.[boundaryLayer.adminLevelLocalNames[1]] === + districtId, + ); + + if (!feature) { + return null; + } + const color = getAAColor(category, phase, true); + return { + ...feature, + properties: { + ...feature.properties, + fillColor: color || 'grey', + }, + }; + }) + .filter(f => f !== null), + }; + }, [data, shouldRenderData]); + + const scalePercent = useAAMarkerScalePercent(map); + + const mainLayerBefore = selectedDistrict + ? 'anticipatory-action-selected-line' + : before; + + return ( + <> + {markers.map(marker => ( + + +
+ {marker.icon} +
+
+
+ ))} + + 0 ? 1 : 0, + }} + /> + + {coloredDistrictsLayer && ( + + + + + )} + + ); + }, +); +export interface LayersProps { + layer: AnticipatoryActionLayerProps; + before?: string; +} + +export default AnticipatoryActionStormLayer; diff --git a/frontend/src/components/MapView/Layers/index.ts b/frontend/src/components/MapView/Layers/index.ts index b7388b31e..53e1219e3 100644 --- a/frontend/src/components/MapView/Layers/index.ts +++ b/frontend/src/components/MapView/Layers/index.ts @@ -5,7 +5,8 @@ import BoundaryLayer from './BoundaryLayer'; import ImpactLayer from './ImpactLayer'; import StaticRasterLayer from './StaticRasterLayer'; import CompositeLayer from './CompositeLayer'; -import AnticipatoryActionLayer from './AnticipatoryActionLayer'; +import AnticipatoryActionDrougthLayer from './AnticipatoryActionDrougthLayer'; +import AnticipatoryActionStormLayer from './AnticipatoryActionStormLayer'; export { ImpactLayer, @@ -15,5 +16,6 @@ export { PointDataLayer, BoundaryLayer, CompositeLayer, - AnticipatoryActionLayer, + AnticipatoryActionDrougthLayer, + AnticipatoryActionStormLayer, }; diff --git a/frontend/src/components/MapView/LeftPanel/AnalysisPanel/index.tsx b/frontend/src/components/MapView/LeftPanel/AnalysisPanel/index.tsx index 3459a1248..eb92fc715 100644 --- a/frontend/src/components/MapView/LeftPanel/AnalysisPanel/index.tsx +++ b/frontend/src/components/MapView/LeftPanel/AnalysisPanel/index.tsx @@ -74,6 +74,7 @@ import { PanelSize, ExposureOperator, ExposureValue, + Panel, } from 'config/types'; import { getAdminLevelCount, getAdminLevelLayer } from 'utils/admin-utils'; import { LayerData } from 'context/layers/layer-data'; @@ -99,7 +100,6 @@ import LayerDropdown from 'components/MapView/Layers/LayerDropdown'; import SimpleDropdown from 'components/Common/SimpleDropdown'; import { leftPanelTabValueSelector, - Panel, setTabValue, } from 'context/leftPanelStateSlice'; import LoadingBlinkingDots from 'components/Common/LoadingBlinkingDots'; diff --git a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionDroughtPanel/index.tsx b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionDroughtPanel/index.tsx new file mode 100644 index 000000000..3a30f06d6 --- /dev/null +++ b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionDroughtPanel/index.tsx @@ -0,0 +1,345 @@ +import { + FormControl, + IconButton, + Input, + MenuItem, + RadioGroup, + Typography, + createStyles, + makeStyles, +} from '@material-ui/core'; +import { black, cyanBlue } from 'muiTheme'; +import React from 'react'; +import { useSafeTranslation } from 'i18n'; +import { ArrowBackIos } from '@material-ui/icons'; +import { useDispatch, useSelector } from 'react-redux'; +import { safeCountry } from 'config'; +import { + AACategoryType, + AAView, + allWindowsKey, +} from 'context/anticipatoryActionStateSlice/types'; +import { AAWindowKeys } from 'config/utils'; +import { + AAAvailableDatesSelector, + AADataSelector, + AAFiltersSelector, + AAMonitoredDistrictsSelector, + AASelectedDistrictSelector, + AAViewSelector, + setAAFilters, + setAASelectedDistrict, + setAAView, +} from 'context/anticipatoryActionStateSlice'; +import { dateRangeSelector } from 'context/mapStateSlice/selectors'; +import { + getAAAvailableDatesCombined, + getRequestDate, +} from 'utils/server-utils'; +import { getFormattedDate } from 'utils/date-utils'; +import { DateFormat } from 'utils/name-utils'; +import { PanelSize } from 'config/types'; +import { StyledCheckboxLabel, StyledRadioLabel, StyledSelect } from '../utils'; +import DistrictView from '../DistrictView/index'; +import HomeTable from '../HomeTable'; +import HowToReadModal from '../HowToReadModal'; +import Timeline from '../Timeline'; +import Forecast from '../Forecast'; + +const isZimbabwe = safeCountry === 'zimbabwe'; + +const checkboxes: { + label: string; + id: Exclude; +}[] = isZimbabwe + ? [ + { label: 'Moderate', id: 'Moderate' }, + { label: 'Below Normal', id: 'Normal' }, + ] + : [ + { label: 'Severe', id: 'Severe' }, + { label: 'Moderate', id: 'Moderate' }, + { label: 'Mild', id: 'Mild' }, + ]; + +function AnticipatoryActionDroughtPanel() { + const classes = useStyles(); + const dispatch = useDispatch(); + const { t } = useSafeTranslation(); + const monitoredDistricts = useSelector(AAMonitoredDistrictsSelector); + const AAAvailableDates = useSelector(AAAvailableDatesSelector); + const selectedDistrict = useSelector(AASelectedDistrictSelector); + const { categories: categoryFilters, selectedIndex } = + useSelector(AAFiltersSelector); + const { startDate: selectedDate } = useSelector(dateRangeSelector); + const aaData = useSelector(AADataSelector); + const view = useSelector(AAViewSelector); + const [indexOptions, setIndexOptions] = React.useState([]); + const [howToReadModalOpen, setHowToReadModalOpen] = React.useState(false); + + const dialogs = [ + { + text: 'How to read this screen', + onclick: () => setHowToReadModalOpen(true), + }, + ]; + + React.useEffect(() => { + if (!selectedDistrict) { + return; + } + const entries = Object.values(aaData) + .map(x => x[selectedDistrict]) + .flat() + .filter(x => x); + + const options = [...new Set(entries.map(x => x.index))]; + setIndexOptions(options); + }, [aaData, selectedDistrict]); + + const layerAvailableDates = + AAAvailableDates !== undefined + ? getAAAvailableDatesCombined(AAAvailableDates) + : []; + const queryDate = getRequestDate(layerAvailableDates, selectedDate); + const date = getFormattedDate(queryDate, DateFormat.Default) as string; + + React.useEffect(() => { + dispatch(setAAFilters({ selectedDate: date })); + }, [date, dispatch]); + + return ( +
{ + switch (view) { + case AAView.Home: + return PanelSize.medium; + case AAView.District: + return PanelSize.auto; + case AAView.Timeline: + return PanelSize.auto; + case AAView.Forecast: + return PanelSize.large; + + default: + console.error(`No width configured for panel ${view}`); + return PanelSize.auto; + } + })(), + }} + > + setHowToReadModalOpen(false)} + /> +
+
+
+ {(view === AAView.District || + view === AAView.Timeline || + view === AAView.Forecast) && ( + { + if (view === AAView.District) { + dispatch(setAASelectedDistrict('')); + dispatch(setAAView(AAView.Home)); + return; + } + if (view === AAView.Timeline) { + dispatch(setAAView(AAView.District)); + dispatch(setAAFilters({ selectedIndex: '' })); + return; + } + if (view === AAView.Forecast) { + dispatch(setAAView(AAView.District)); + } + }} + > + + + )} + } + renderValue={() => ( + + {t(selectedDistrict) || t('Summary')}{' '} + {view === AAView.Timeline && t('Timeline')} + {view === AAView.Forecast && t('Forecast')} + + )} + > + { + dispatch(setAASelectedDistrict('')); + dispatch(setAAView(AAView.Home)); + }} + > + {t('Summary')} + + {monitoredDistricts.map(x => ( + { + dispatch(setAASelectedDistrict(x.name)); + if (view === AAView.Home) { + dispatch(setAAView(AAView.District)); + } + }} + > + {t(x.name)} + + ))} + +
+
+ +
+ + + dispatch(setAAFilters({ selectedWindow: val as any })) + } + > + + {AAWindowKeys.map(x => ( + + ))} + + +
+ +
+ {checkboxes.map(x => ( + { + const { checked } = e.target; + dispatch(setAAFilters({ categories: { [x.id]: checked } })); + }, + }} + label={t(x.label)} + /> + ))} +
+ {!selectedDistrict && ( + + {t('Summary data as of ')} + {getFormattedDate(selectedDate, 'locale')} + + )} + + {view === AAView.District && ( + + {t( + monitoredDistricts.find(x => x.name === selectedDistrict) + ?.vulnerability || '', + )} + + )} + {view === AAView.Timeline && ( +
+ } + renderValue={() => ( + + {t(selectedIndex) || t('Indicators')} + + )} + > + { + dispatch(setAAFilters({ selectedIndex: '' })); + }} + > + {t('All')} + + {indexOptions.map(x => ( + { + dispatch(setAAFilters({ selectedIndex: x })); + }} + > + {t(x)} + + ))} + +
+ )} +
+ {view === AAView.Home && } + {view === AAView.District && } + {view === AAView.Timeline && } + {view === AAView.Forecast && } +
+ ); +} + +const useStyles = makeStyles(() => + createStyles({ + anticipatoryActionPanel: { + display: 'flex', + flexDirection: 'column', + gap: '1rem', + height: '100%', + justifyContent: 'space-between', + }, + headerWrapper: { + padding: '1rem 1rem 0 1rem', + display: 'flex', + flexDirection: 'column', + gap: '0.50rem', + }, + radioButtonGroup: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + footerWrapper: { display: 'flex', flexDirection: 'column' }, + footerActionsWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + padding: '0.5rem', + gap: '1rem', + }, + footerLinksWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + padding: '0.5rem', + }, + footerButton: { borderColor: cyanBlue, color: black }, + footerLink: { + textDecoration: 'underline', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + }, + titleSelectWrapper: { + display: 'flex', + alignItems: 'center', + width: '100%', + }, + }), +); + +export default AnticipatoryActionDroughtPanel; diff --git a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionStormPanel/index.tsx b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionStormPanel/index.tsx new file mode 100644 index 000000000..32bccaa73 --- /dev/null +++ b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/AnticipatoryActionStormPanel/index.tsx @@ -0,0 +1,345 @@ +import { + FormControl, + IconButton, + Input, + MenuItem, + RadioGroup, + Typography, + createStyles, + makeStyles, +} from '@material-ui/core'; +import { black, cyanBlue } from 'muiTheme'; +import React from 'react'; +import { useSafeTranslation } from 'i18n'; +import { ArrowBackIos } from '@material-ui/icons'; +import { useDispatch, useSelector } from 'react-redux'; +import { safeCountry } from 'config'; +import { + AACategoryType, + AAView, + allWindowsKey, +} from 'context/anticipatoryActionStateSlice/types'; +import { AAWindowKeys } from 'config/utils'; +import { + AAAvailableDatesSelector, + AADataSelector, + AAFiltersSelector, + AAMonitoredDistrictsSelector, + AASelectedDistrictSelector, + AAViewSelector, + setAAFilters, + setAASelectedDistrict, + setAAView, +} from 'context/anticipatoryActionStateSlice'; +import { dateRangeSelector } from 'context/mapStateSlice/selectors'; +import { + getAAAvailableDatesCombined, + getRequestDate, +} from 'utils/server-utils'; +import { getFormattedDate } from 'utils/date-utils'; +import { DateFormat } from 'utils/name-utils'; +import { PanelSize } from 'config/types'; +import { StyledCheckboxLabel, StyledRadioLabel, StyledSelect } from '../utils'; +import DistrictView from '../DistrictView/index'; +import HomeTable from '../HomeTable'; +import HowToReadModal from '../HowToReadModal'; +import Timeline from '../Timeline'; +import Forecast from '../Forecast'; + +const isZimbabwe = safeCountry === 'zimbabwe'; + +const checkboxes: { + label: string; + id: Exclude; +}[] = isZimbabwe + ? [ + { label: 'Moderate', id: 'Moderate' }, + { label: 'Below Normal', id: 'Normal' }, + ] + : [ + { label: 'Severe', id: 'Severe' }, + { label: 'Moderate', id: 'Moderate' }, + { label: 'Mild', id: 'Mild' }, + ]; + +function AnticipatoryActionStormPanel() { + const classes = useStyles(); + const dispatch = useDispatch(); + const { t } = useSafeTranslation(); + const monitoredDistricts = useSelector(AAMonitoredDistrictsSelector); + const AAAvailableDates = useSelector(AAAvailableDatesSelector); + const selectedDistrict = useSelector(AASelectedDistrictSelector); + const { categories: categoryFilters, selectedIndex } = + useSelector(AAFiltersSelector); + const { startDate: selectedDate } = useSelector(dateRangeSelector); + const aaData = useSelector(AADataSelector); + const view = useSelector(AAViewSelector); + const [indexOptions, setIndexOptions] = React.useState([]); + const [howToReadModalOpen, setHowToReadModalOpen] = React.useState(false); + + const dialogs = [ + { + text: 'How to read this screen', + onclick: () => setHowToReadModalOpen(true), + }, + ]; + + React.useEffect(() => { + if (!selectedDistrict) { + return; + } + const entries = Object.values(aaData) + .map(x => x[selectedDistrict]) + .flat() + .filter(x => x); + + const options = [...new Set(entries.map(x => x.index))]; + setIndexOptions(options); + }, [aaData, selectedDistrict]); + + const layerAvailableDates = + AAAvailableDates !== undefined + ? getAAAvailableDatesCombined(AAAvailableDates) + : []; + const queryDate = getRequestDate(layerAvailableDates, selectedDate); + const date = getFormattedDate(queryDate, DateFormat.Default) as string; + + React.useEffect(() => { + dispatch(setAAFilters({ selectedDate: date })); + }, [date, dispatch]); + + return ( +
{ + switch (view) { + case AAView.Home: + return PanelSize.medium; + case AAView.District: + return PanelSize.auto; + case AAView.Timeline: + return PanelSize.auto; + case AAView.Forecast: + return PanelSize.large; + + default: + console.error(`No width configured for panel ${view}`); + return PanelSize.auto; + } + })(), + }} + > + setHowToReadModalOpen(false)} + /> +
+
+
+ {(view === AAView.District || + view === AAView.Timeline || + view === AAView.Forecast) && ( + { + if (view === AAView.District) { + dispatch(setAASelectedDistrict('')); + dispatch(setAAView(AAView.Home)); + return; + } + if (view === AAView.Timeline) { + dispatch(setAAView(AAView.District)); + dispatch(setAAFilters({ selectedIndex: '' })); + return; + } + if (view === AAView.Forecast) { + dispatch(setAAView(AAView.District)); + } + }} + > + + + )} + } + renderValue={() => ( + + {t(selectedDistrict) || t('STORM')}{' '} + {view === AAView.Timeline && t('Timeline')} + {view === AAView.Forecast && t('Forecast')} + + )} + > + { + dispatch(setAASelectedDistrict('')); + dispatch(setAAView(AAView.Home)); + }} + > + {t('STORM')} + + {monitoredDistricts.map(x => ( + { + dispatch(setAASelectedDistrict(x.name)); + if (view === AAView.Home) { + dispatch(setAAView(AAView.District)); + } + }} + > + {t(x.name)} + + ))} + +
+
+ +
+ + + dispatch(setAAFilters({ selectedWindow: val as any })) + } + > + + {AAWindowKeys.map(x => ( + + ))} + + +
+ +
+ {checkboxes.map(x => ( + { + const { checked } = e.target; + dispatch(setAAFilters({ categories: { [x.id]: checked } })); + }, + }} + label={t(x.label)} + /> + ))} +
+ {!selectedDistrict && ( + + {t('STORM data as of ')} + {getFormattedDate(selectedDate, 'locale')} + + )} + + {view === AAView.District && ( + + {t( + monitoredDistricts.find(x => x.name === selectedDistrict) + ?.vulnerability || '', + )} + + )} + {view === AAView.Timeline && ( +
+ } + renderValue={() => ( + + {t(selectedIndex) || t('Indicators')} + + )} + > + { + dispatch(setAAFilters({ selectedIndex: '' })); + }} + > + {t('All')} + + {indexOptions.map(x => ( + { + dispatch(setAAFilters({ selectedIndex: x })); + }} + > + {t(x)} + + ))} + +
+ )} +
+ {view === AAView.Home && } + {view === AAView.District && } + {view === AAView.Timeline && } + {view === AAView.Forecast && } +
+ ); +} + +const useStyles = makeStyles(() => + createStyles({ + anticipatoryActionPanel: { + display: 'flex', + flexDirection: 'column', + gap: '1rem', + height: '100%', + justifyContent: 'space-between', + }, + headerWrapper: { + padding: '1rem 1rem 0 1rem', + display: 'flex', + flexDirection: 'column', + gap: '0.50rem', + }, + radioButtonGroup: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + footerWrapper: { display: 'flex', flexDirection: 'column' }, + footerActionsWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + padding: '0.5rem', + gap: '1rem', + }, + footerLinksWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + padding: '0.5rem', + }, + footerButton: { borderColor: cyanBlue, color: black }, + footerLink: { + textDecoration: 'underline', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + }, + titleSelectWrapper: { + display: 'flex', + alignItems: 'center', + width: '100%', + }, + }), +); + +export default AnticipatoryActionStormPanel; diff --git a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/DistrictView/index.test.tsx b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/DistrictView/index.test.tsx index a5bb092fb..ef25d14df 100644 --- a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/DistrictView/index.test.tsx +++ b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/DistrictView/index.test.tsx @@ -3,7 +3,7 @@ import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import { AnticipatoryActionState } from 'context/anticipatoryActionStateSlice/types'; -import { Panel } from 'context/leftPanelStateSlice'; +import { Panel } from 'config/types'; import DistrictView from '.'; import { defaultDialogs, mockAAData } from '../test.utils'; import { districtViewTransform } from './utils'; diff --git a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/Forecast/index.test.tsx b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/Forecast/index.test.tsx index 659e65058..969de2e31 100644 --- a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/Forecast/index.test.tsx +++ b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/Forecast/index.test.tsx @@ -3,7 +3,7 @@ import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import { AnticipatoryActionState } from 'context/anticipatoryActionStateSlice/types'; -import { Panel } from 'context/leftPanelStateSlice'; +import { Panel } from 'config/types'; import { defaultDialogs, mockAAData } from '../test.utils'; import { forecastTransform } from './utils'; import Forecast from '.'; diff --git a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/HomeTable/index.test.tsx b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/HomeTable/index.test.tsx index 788b0e429..d31c989ac 100644 --- a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/HomeTable/index.test.tsx +++ b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/HomeTable/index.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; -import { Panel } from 'context/leftPanelStateSlice'; +import { Panel } from 'config/types'; import HomeTable from '.'; import { defaultDialogs, mockAARenderedDistricts } from '../test.utils'; diff --git a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/Timeline/index.test.tsx b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/Timeline/index.test.tsx index a95f67598..854f66c32 100644 --- a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/Timeline/index.test.tsx +++ b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/Timeline/index.test.tsx @@ -3,7 +3,7 @@ import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import { AnticipatoryActionState } from 'context/anticipatoryActionStateSlice/types'; -import { Panel } from 'context/leftPanelStateSlice'; +import { Panel } from 'config/types'; import { defaultDialogs, mockAAData } from '../test.utils'; import { timelineTransform } from './utils'; import Timeline from '.'; diff --git a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/index.tsx b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/index.tsx index ffb269aa6..5da7e1478 100644 --- a/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/index.tsx +++ b/frontend/src/components/MapView/LeftPanel/AnticipatoryActionPanel/index.tsx @@ -1,345 +1,4 @@ -import { - FormControl, - IconButton, - Input, - MenuItem, - RadioGroup, - Typography, - createStyles, - makeStyles, -} from '@material-ui/core'; -import { black, cyanBlue } from 'muiTheme'; -import React from 'react'; -import { useSafeTranslation } from 'i18n'; -import { ArrowBackIos } from '@material-ui/icons'; -import { useDispatch, useSelector } from 'react-redux'; -import { safeCountry } from 'config'; -import { - AACategoryType, - AAView, - allWindowsKey, -} from 'context/anticipatoryActionStateSlice/types'; -import { AAWindowKeys } from 'config/utils'; -import { - AAAvailableDatesSelector, - AADataSelector, - AAFiltersSelector, - AAMonitoredDistrictsSelector, - AASelectedDistrictSelector, - AAViewSelector, - setAAFilters, - setAASelectedDistrict, - setAAView, -} from 'context/anticipatoryActionStateSlice'; -import { dateRangeSelector } from 'context/mapStateSlice/selectors'; -import { - getAAAvailableDatesCombined, - getRequestDate, -} from 'utils/server-utils'; -import { getFormattedDate } from 'utils/date-utils'; -import { DateFormat } from 'utils/name-utils'; -import { PanelSize } from 'config/types'; -import { StyledCheckboxLabel, StyledRadioLabel, StyledSelect } from './utils'; -import DistrictView from './DistrictView/index'; -import HomeTable from './HomeTable'; -import HowToReadModal from './HowToReadModal'; -import Timeline from './Timeline'; -import Forecast from './Forecast'; +import AnticipatoryActionDroughtPanel from './AnticipatoryActionDroughtPanel'; +import AnticipatoryActionStormPanel from './AnticipatoryActionStormPanel'; -const isZimbabwe = safeCountry === 'zimbabwe'; - -const checkboxes: { - label: string; - id: Exclude; -}[] = isZimbabwe - ? [ - { label: 'Moderate', id: 'Moderate' }, - { label: 'Below Normal', id: 'Normal' }, - ] - : [ - { label: 'Severe', id: 'Severe' }, - { label: 'Moderate', id: 'Moderate' }, - { label: 'Mild', id: 'Mild' }, - ]; - -function AnticipatoryActionPanel() { - const classes = useStyles(); - const dispatch = useDispatch(); - const { t } = useSafeTranslation(); - const monitoredDistricts = useSelector(AAMonitoredDistrictsSelector); - const AAAvailableDates = useSelector(AAAvailableDatesSelector); - const selectedDistrict = useSelector(AASelectedDistrictSelector); - const { categories: categoryFilters, selectedIndex } = - useSelector(AAFiltersSelector); - const { startDate: selectedDate } = useSelector(dateRangeSelector); - const aaData = useSelector(AADataSelector); - const view = useSelector(AAViewSelector); - const [indexOptions, setIndexOptions] = React.useState([]); - const [howToReadModalOpen, setHowToReadModalOpen] = React.useState(false); - - const dialogs = [ - { - text: 'How to read this screen', - onclick: () => setHowToReadModalOpen(true), - }, - ]; - - React.useEffect(() => { - if (!selectedDistrict) { - return; - } - const entries = Object.values(aaData) - .map(x => x[selectedDistrict]) - .flat() - .filter(x => x); - - const options = [...new Set(entries.map(x => x.index))]; - setIndexOptions(options); - }, [aaData, selectedDistrict]); - - const layerAvailableDates = - AAAvailableDates !== undefined - ? getAAAvailableDatesCombined(AAAvailableDates) - : []; - const queryDate = getRequestDate(layerAvailableDates, selectedDate); - const date = getFormattedDate(queryDate, DateFormat.Default) as string; - - React.useEffect(() => { - dispatch(setAAFilters({ selectedDate: date })); - }, [date, dispatch]); - - return ( -
{ - switch (view) { - case AAView.Home: - return PanelSize.medium; - case AAView.District: - return PanelSize.auto; - case AAView.Timeline: - return PanelSize.auto; - case AAView.Forecast: - return PanelSize.large; - - default: - console.error(`No width configured for panel ${view}`); - return PanelSize.auto; - } - })(), - }} - > - setHowToReadModalOpen(false)} - /> -
-
-
- {(view === AAView.District || - view === AAView.Timeline || - view === AAView.Forecast) && ( - { - if (view === AAView.District) { - dispatch(setAASelectedDistrict('')); - dispatch(setAAView(AAView.Home)); - return; - } - if (view === AAView.Timeline) { - dispatch(setAAView(AAView.District)); - dispatch(setAAFilters({ selectedIndex: '' })); - return; - } - if (view === AAView.Forecast) { - dispatch(setAAView(AAView.District)); - } - }} - > - - - )} - } - renderValue={() => ( - - {t(selectedDistrict) || t('Summary')}{' '} - {view === AAView.Timeline && t('Timeline')} - {view === AAView.Forecast && t('Forecast')} - - )} - > - { - dispatch(setAASelectedDistrict('')); - dispatch(setAAView(AAView.Home)); - }} - > - {t('Summary')} - - {monitoredDistricts.map(x => ( - { - dispatch(setAASelectedDistrict(x.name)); - if (view === AAView.Home) { - dispatch(setAAView(AAView.District)); - } - }} - > - {t(x.name)} - - ))} - -
-
- -
- - - dispatch(setAAFilters({ selectedWindow: val as any })) - } - > - - {AAWindowKeys.map(x => ( - - ))} - - -
- -
- {checkboxes.map(x => ( - { - const { checked } = e.target; - dispatch(setAAFilters({ categories: { [x.id]: checked } })); - }, - }} - label={t(x.label)} - /> - ))} -
- {!selectedDistrict && ( - - {t('Summary data as of ')} - {getFormattedDate(selectedDate, 'locale')} - - )} - - {view === AAView.District && ( - - {t( - monitoredDistricts.find(x => x.name === selectedDistrict) - ?.vulnerability || '', - )} - - )} - {view === AAView.Timeline && ( -
- } - renderValue={() => ( - - {t(selectedIndex) || t('Indicators')} - - )} - > - { - dispatch(setAAFilters({ selectedIndex: '' })); - }} - > - {t('All')} - - {indexOptions.map(x => ( - { - dispatch(setAAFilters({ selectedIndex: x })); - }} - > - {t(x)} - - ))} - -
- )} -
- {view === AAView.Home && } - {view === AAView.District && } - {view === AAView.Timeline && } - {view === AAView.Forecast && } -
- ); -} - -const useStyles = makeStyles(() => - createStyles({ - anticipatoryActionPanel: { - display: 'flex', - flexDirection: 'column', - gap: '1rem', - height: '100%', - justifyContent: 'space-between', - }, - headerWrapper: { - padding: '1rem 1rem 0 1rem', - display: 'flex', - flexDirection: 'column', - gap: '0.50rem', - }, - radioButtonGroup: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'flex-end', - }, - footerWrapper: { display: 'flex', flexDirection: 'column' }, - footerActionsWrapper: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - padding: '0.5rem', - gap: '1rem', - }, - footerLinksWrapper: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - padding: '0.5rem', - }, - footerButton: { borderColor: cyanBlue, color: black }, - footerLink: { - textDecoration: 'underline', - backgroundColor: 'transparent', - border: 'none', - cursor: 'pointer', - }, - titleSelectWrapper: { - display: 'flex', - alignItems: 'center', - width: '100%', - }, - }), -); - -export default AnticipatoryActionPanel; +export { AnticipatoryActionDroughtPanel, AnticipatoryActionStormPanel }; diff --git a/frontend/src/components/MapView/LeftPanel/ChartsPanel/index.tsx b/frontend/src/components/MapView/LeftPanel/ChartsPanel/index.tsx index 4285fd8d1..0bd9d9855 100644 --- a/frontend/src/components/MapView/LeftPanel/ChartsPanel/index.tsx +++ b/frontend/src/components/MapView/LeftPanel/ChartsPanel/index.tsx @@ -33,13 +33,14 @@ import { BoundaryLayerProps, PanelSize, WMSLayerProps, + Panel, } from 'config/types'; import { getBoundaryLayersByAdminLevel, getWMSLayersWithChart, } from 'config/utils'; import { LayerData } from 'context/layers/layer-data'; -import { leftPanelTabValueSelector, Panel } from 'context/leftPanelStateSlice'; +import { leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; import { layerDataSelector } from 'context/mapStateSlice/selectors'; import { useSafeTranslation } from 'i18n'; import { buildCsvFileName } from 'components/MapView/utils'; diff --git a/frontend/src/components/MapView/LeftPanel/TablesPanel/index.tsx b/frontend/src/components/MapView/LeftPanel/TablesPanel/index.tsx index c3622f988..f10c66c3b 100644 --- a/frontend/src/components/MapView/LeftPanel/TablesPanel/index.tsx +++ b/frontend/src/components/MapView/LeftPanel/TablesPanel/index.tsx @@ -22,6 +22,7 @@ import { MenuItemType, PanelSize, TableType, + Panel, } from 'config/types'; import { useSafeTranslation } from 'i18n'; import { @@ -31,7 +32,7 @@ import { isLoading as tableLoading, loadTable, } from 'context/tableStateSlice'; -import { Panel, leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; +import { leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; import TablesActions from './TablesActions'; import DataTable from './DataTable'; import { tablesMenuItems } from '../utils'; diff --git a/frontend/src/components/MapView/LeftPanel/index.tsx b/frontend/src/components/MapView/LeftPanel/index.tsx index 518659507..838443c78 100644 --- a/frontend/src/components/MapView/LeftPanel/index.tsx +++ b/frontend/src/components/MapView/LeftPanel/index.tsx @@ -2,14 +2,15 @@ import { Drawer, Theme, createStyles, makeStyles } from '@material-ui/core'; import React, { memo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { - Panel, leftPanelTabValueSelector, setTabValue, } from 'context/leftPanelStateSlice'; +import { AnticipatoryAction, Panel } from 'config/types'; import { - AALayerId, + AALayerIds, LayerDefinitions, areChartLayersAvailable, + isAnticipatoryActionLayer, } from 'config/utils'; import { getUrlKey, useUrlHistory } from 'utils/url-utils'; import { layersSelector, mapSelector } from 'context/mapStateSlice/selectors'; @@ -27,7 +28,10 @@ import { getAAAvailableDatesCombined } from 'utils/server-utils'; import AnalysisPanel from './AnalysisPanel'; import ChartsPanel from './ChartsPanel'; import TablesPanel from './TablesPanel'; -import AnticipatoryActionPanel from './AnticipatoryActionPanel'; +import { + AnticipatoryActionDroughtPanel, + AnticipatoryActionStormPanel, +} from './AnticipatoryActionPanel'; import LayersPanel from './layersPanel'; import { areTablesAvailable, isAnticipatoryActionAvailable } from './utils'; import { toggleRemoveLayer } from './layersPanel/MenuItem/MenuSwitch/SwitchItem/utils'; @@ -75,19 +79,22 @@ const LeftPanel = memo(() => { // Sync serverAvailableDates with AAAvailableDates when the latter updates. React.useEffect(() => { if (AAAvailableDates) { - dispatch( - updateLayersCapabilities({ - ...serverAvailableDates, - [AALayerId]: getAAAvailableDatesCombined(AAAvailableDates), + const updatedCapabilities = AALayerIds.reduce( + (acc, layerId) => ({ + ...acc, + [layerId]: getAAAvailableDatesCombined(AAAvailableDates), }), + { ...serverAvailableDates }, ); + dispatch(updateLayersCapabilities(updatedCapabilities)); } // To avoid an infinite loop, we only want to run this effect when AAAvailableDates changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [AAAvailableDates, dispatch]); const AALayerInUrl = React.useMemo( - () => selectedLayers.find(x => x.id === AALayerId), + () => + selectedLayers.find(x => AALayerIds.includes(x.id as AnticipatoryAction)), [selectedLayers], ); @@ -96,18 +103,22 @@ const LeftPanel = memo(() => { // TODO: update Object.keys(AAData).length === 0 condition with something more solid // Move to AA tab when directly linked there if ( - tabValue !== Panel.AnticipatoryAction && + !isAnticipatoryActionLayer(tabValue) && AALayerInUrl !== undefined && Object.keys(AAData['Window 1']).length === 0 ) { - dispatch(setTabValue(Panel.AnticipatoryAction)); + if (AALayerInUrl.id === AnticipatoryAction.drought) { + dispatch(setTabValue(Panel.AnticipatoryActionDrought)); + } else if (AALayerInUrl.id === AnticipatoryAction.storm) { + dispatch(setTabValue(Panel.AnticipatoryActionStorm)); + } } }, [AAData, AALayerInUrl, dispatch, tabValue]); // Remove from url when leaving from AA tab React.useEffect(() => { if ( - tabValue !== Panel.AnticipatoryAction && + !isAnticipatoryActionLayer(tabValue) && tabValue !== Panel.None && AALayerInUrl !== undefined && Object.keys(AAData['Window 1']).length !== 0 @@ -124,7 +135,7 @@ const LeftPanel = memo(() => { // fetch csv data when loading AA page React.useEffect(() => { - if (tabValue !== Panel.AnticipatoryAction) { + if (!isAnticipatoryActionLayer(tabValue)) { return; } dispatch(loadAAData()); @@ -132,11 +143,17 @@ const LeftPanel = memo(() => { // Add layers to url React.useEffect(() => { - if (tabValue !== Panel.AnticipatoryAction) { + if (!isAnticipatoryActionLayer(tabValue)) { return; } - const layer = LayerDefinitions[AALayerId]; + const selectedLayerId = AALayerIds.find(x => x === tabValue); + + if (!selectedLayerId) { + return; + } + // Add default AA layer to url + const layer = LayerDefinitions[selectedLayerId]; // Add to url when getting to AA tab if (AALayerInUrl !== undefined || !layer) { @@ -196,11 +213,21 @@ const LeftPanel = memo(() => { if (!isAnticipatoryActionAvailable) { return null; } - return ( - - - - ); + if (tabValue === Panel.AnticipatoryActionDrought) { + return ( + + + + ); + } + if (tabValue === Panel.AnticipatoryActionStorm) { + return ( + + + + ); + } + return null; }, [tabValue]); return ( diff --git a/frontend/src/components/MapView/LeftPanel/layersPanel/MenuItem/MenuSwitch/SwitchItem/ExposureAnalysisOption.tsx b/frontend/src/components/MapView/LeftPanel/layersPanel/MenuItem/MenuSwitch/SwitchItem/ExposureAnalysisOption.tsx index 55b2d5937..36b057ae0 100644 --- a/frontend/src/components/MapView/LeftPanel/layersPanel/MenuItem/MenuSwitch/SwitchItem/ExposureAnalysisOption.tsx +++ b/frontend/src/components/MapView/LeftPanel/layersPanel/MenuItem/MenuSwitch/SwitchItem/ExposureAnalysisOption.tsx @@ -6,6 +6,7 @@ import { ExposedPopulationDefinition, GeometryType, LayerType, + Panel, } from 'config/types'; import { ReportsDefinitions, TableKey } from 'config/utils'; import { @@ -16,7 +17,7 @@ import { setCurrentDataDefinition, setExposureLayerId, } from 'context/analysisResultStateSlice'; -import { Panel, setTabValue } from 'context/leftPanelStateSlice'; +import { setTabValue } from 'context/leftPanelStateSlice'; import { dateRangeSelector } from 'context/mapStateSlice/selectors'; import { useSafeTranslation } from 'i18n'; import { Extent } from 'components/MapView/Layers/raster-utils'; diff --git a/frontend/src/components/MapView/Legends/LegendItemsList.tsx b/frontend/src/components/MapView/Legends/LegendItemsList.tsx index 55ea9b532..0e2f764dc 100644 --- a/frontend/src/components/MapView/Legends/LegendItemsList.tsx +++ b/frontend/src/components/MapView/Legends/LegendItemsList.tsx @@ -6,13 +6,13 @@ import { invertedColorsSelector, isAnalysisLayerActiveSelector, } from 'context/analysisResultStateSlice'; -import { LayerType } from 'config/types'; +import { AnticipatoryAction, LayerType } from 'config/types'; import { BaselineLayerResult } from 'utils/analysis-utils'; import useLayers from 'utils/layers-utils'; import { createGetLegendGraphicUrl } from 'prism-common'; import { useSafeTranslation } from 'i18n'; import { List } from '@material-ui/core'; -import { AALayerId } from 'config/utils'; +import { AALayerIds } from 'config/utils'; import AALegend from '../LeftPanel/AnticipatoryActionPanel/AALegend'; import LegendItem from './LegendItem'; import LegendImpactResult from './LegendImpactResult'; @@ -37,7 +37,8 @@ function LegendItemsList({ const { selectedLayers, adminBoundariesExtent } = useLayers(); const AALayerInUrl = React.useMemo( - () => selectedLayers.find(x => x.id === AALayerId), + () => + selectedLayers.find(x => AALayerIds.includes(x.id as AnticipatoryAction)), [selectedLayers], ); diff --git a/frontend/src/components/MapView/Map/index.tsx b/frontend/src/components/MapView/Map/index.tsx index 3bf3fd1ba..e6dd04a48 100644 --- a/frontend/src/components/MapView/Map/index.tsx +++ b/frontend/src/components/MapView/Map/index.tsx @@ -14,7 +14,7 @@ import { setMap } from 'context/mapStateSlice'; import { appConfig } from 'config'; import useMapOnClick from 'components/MapView/useMapOnClick'; import { setBounds, setLocation } from 'context/mapBoundaryInfoStateSlice'; -import { DiscriminateUnion, LayerKey, LayerType } from 'config/types'; +import { DiscriminateUnion, LayerKey, LayerType, Panel } from 'config/types'; import { setLoadingLayerIds } from 'context/mapTileLoadingStateSlice'; import { firstBoundaryOnView, @@ -24,7 +24,8 @@ import { import { mapSelector } from 'context/mapStateSlice/selectors'; import { AdminLevelDataLayer, - AnticipatoryActionLayer, + AnticipatoryActionDrougthLayer, + AnticipatoryActionStormLayer, BoundaryLayer, CompositeLayer, ImpactLayer, @@ -37,7 +38,7 @@ import MapGL, { MapEvent, MapRef } from 'react-map-gl/maplibre'; import { MapSourceDataEvent, Map as MaplibreMap } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { Panel, leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; +import { leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; import { mapStyle } from './utils'; type LayerComponentsMap = { @@ -54,8 +55,11 @@ const componentTypes: LayerComponentsMap = { point_data: { component: PointDataLayer }, static_raster: { component: StaticRasterLayer }, composite: { component: CompositeLayer }, - anticipatory_action: { - component: AnticipatoryActionLayer, + anticipatory_action_drought: { + component: AnticipatoryActionDrougthLayer, + }, + anticipatory_action_storm: { + component: AnticipatoryActionStormLayer, }, }; @@ -227,7 +231,10 @@ const MapComponent = memo(() => { return createElement(component as any, { key: layer.id, layer, - before: getBeforeId(index, layer.type === 'anticipatory_action'), + before: getBeforeId( + index, + layer.type.startsWith('anticipatory_action'), + ), }); })} diff --git a/frontend/src/components/MapView/utils.ts b/frontend/src/components/MapView/utils.ts index 295850393..6b38130a6 100644 --- a/frontend/src/components/MapView/utils.ts +++ b/frontend/src/components/MapView/utils.ts @@ -1,7 +1,7 @@ import { orderBy, snakeCase, values } from 'lodash'; import { TFunction } from 'i18next'; import { Dispatch } from 'redux'; -import { LayerDefinitions } from 'config/utils'; +import { isAnticipatoryActionLayer, LayerDefinitions } from 'config/utils'; import { formatFeatureInfo } from 'utils/server-utils'; import { AvailableDates, @@ -198,7 +198,7 @@ export const checkLayerAvailableDatesAndContinueOrRemove = ( const { id: layerId } = layer as any; if ( serverAvailableDates[layerId]?.length !== 0 || - layer.type === 'anticipatory_action' + isAnticipatoryActionLayer(layer.type) ) { return; } diff --git a/frontend/src/components/NavBar/PanelButton/index.tsx b/frontend/src/components/NavBar/PanelButton/index.tsx new file mode 100644 index 000000000..d3600020a --- /dev/null +++ b/frontend/src/components/NavBar/PanelButton/index.tsx @@ -0,0 +1,78 @@ +import { Button, IconButton, Badge, Typography } from '@material-ui/core'; +import React from 'react'; +import { ExpandMore } from '@material-ui/icons'; +import { black, cyanBlue } from 'muiTheme'; +import useLayers from 'utils/layers-utils'; +import { useSelector } from 'react-redux'; +import { analysisResultSelector } from 'context/analysisResultStateSlice'; +import { Panel } from 'config/types'; + +function PanelButton({ + panel, + selected, + handleClick, + isMobile, + buttonText, +}: { + panel: any; + selected: boolean; + handleClick: (event: React.MouseEvent) => void; + isMobile: boolean; + buttonText: string; +}) { + const { numberOfActiveLayers } = useLayers(); + const analysisData = useSelector(analysisResultSelector); + const badgeContent = numberOfActiveLayers + Number(Boolean(analysisData)); + const Wrap = + badgeContent >= 1 && panel.panel === Panel.Layers + ? // eslint-disable-next-line react/no-unused-prop-types + ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + : ({ children }: { children: React.ReactNode }) => children; + + const commonStyles = { + backgroundColor: selected ? cyanBlue : undefined, + color: selected ? black : 'white', + }; + + const renderButtonContent = ( + + {buttonText} + + ); + + return isMobile ? ( + + + {panel.icon} + + + ) : ( + + ); +} + +export default PanelButton; diff --git a/frontend/src/components/NavBar/PanelMenu/index.tsx b/frontend/src/components/NavBar/PanelMenu/index.tsx new file mode 100644 index 000000000..c3d866fb5 --- /dev/null +++ b/frontend/src/components/NavBar/PanelMenu/index.tsx @@ -0,0 +1,43 @@ +import { Menu, MenuItem } from '@material-ui/core'; +import { PanelItem } from 'config/types'; + +function PanelMenu({ + panel, + menuAnchor, + handleMenuClose, + handleChildClick, + selected, +}: { + panel: PanelItem; + menuAnchor: HTMLElement | null; + handleMenuClose: () => void; + handleChildClick: (childPanel: PanelItem) => void; + selected: string; +}) { + const validSelected = panel.children?.find( + (child: PanelItem) => child.panel === selected, + ); + + return ( + + {panel.children?.map((child: PanelItem) => ( + { + handleChildClick(child); + handleMenuClose(); + }} + selected={validSelected?.panel === child.panel} + > + {child.label} + + ))} + + ); +} + +export default PanelMenu; diff --git a/frontend/src/components/NavBar/PrintImage/index.test.tsx b/frontend/src/components/NavBar/PrintImage/index.test.tsx index fec27b5d0..ee1d77ed7 100644 --- a/frontend/src/components/NavBar/PrintImage/index.test.tsx +++ b/frontend/src/components/NavBar/PrintImage/index.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; -import { Panel } from 'context/leftPanelStateSlice'; +import { Panel } from 'config/types'; import Download from '.'; const mockStore = configureStore([]); diff --git a/frontend/src/components/NavBar/PrintImage/printPreview.tsx b/frontend/src/components/NavBar/PrintImage/printPreview.tsx index 886f71561..66396130b 100644 --- a/frontend/src/components/NavBar/PrintImage/printPreview.tsx +++ b/frontend/src/components/NavBar/PrintImage/printPreview.tsx @@ -9,9 +9,9 @@ import { lightGrey } from 'muiTheme'; import { AAMarkersSelector } from 'context/anticipatoryActionStateSlice'; import { useAAMarkerScalePercent } from 'utils/map-utils'; import LegendItemsList from 'components/MapView/Legends/LegendItemsList'; -import { Panel, leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; +import { leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; +import { Panel, AdminLevelDataLayerProps } from 'config/types'; import useLayers from 'utils/layers-utils'; -import { AdminLevelDataLayerProps } from 'config/types'; import { addFillPatternImagesInMap } from 'components/MapView/Layers/AdminLevelDataLayer/utils'; import { mapStyle } from 'components/MapView/Map/utils'; import { @@ -277,7 +277,7 @@ function PrintPreview() { mapStyle={selectedMapStyle || mapStyle.toString()} maxBounds={selectedMap.getMaxBounds() ?? undefined} > - {tabValue === Panel.AnticipatoryAction && + {tabValue === Panel.AnticipatoryActionDrought && AAMarkers.map(marker => ( }, ...(areChartLayersAvailable ? [{ panel: Panel.Charts, label: 'Charts', icon: }] @@ -64,9 +61,20 @@ const panels = [ ...(isAnticipatoryActionAvailable ? [ { - panel: Panel.AnticipatoryAction, - label: 'A. Action', + label: 'A. Actions', icon: , + children: [ + { + panel: Panel.AnticipatoryActionDrought, + label: 'A. Action Drought', + icon: , + }, + { + panel: Panel.AnticipatoryActionStorm, + label: 'A. Action Storm', + icon: , + }, + ], }, ] : []), @@ -80,14 +88,15 @@ function NavBar() { const dispatch = useDispatch(); const classes = useStyles(); const tabValue = useSelector(leftPanelTabValueSelector); - const analysisData = useSelector(analysisResultSelector); const theme = useTheme(); const smDown = useMediaQuery(theme.breakpoints.down('sm')); const mdUp = useMediaQuery(theme.breakpoints.up('md')); - - const { numberOfActiveLayers } = useLayers(); - - const badgeContent = numberOfActiveLayers + Number(Boolean(analysisData)); + const [menuAnchor, setMenuAnchor] = useState<{ + [key: string]: HTMLElement | null; + }>({}); + const [selectedChild, setSelectedChild] = useState>( + {}, + ); const rightSideLinks = [ { @@ -109,6 +118,29 @@ function NavBar() { )); + const handleMenuOpen = ( + key: string, + event: React.MouseEvent, + ) => { + setMenuAnchor(prev => ({ ...prev, [key]: event.currentTarget })); + }; + + const handleMenuClose = (key: string) => { + setMenuAnchor(prev => ({ ...prev, [key]: null })); + }; + + const handlePanelClick = (panel: Panel) => { + dispatch(setTabValue(panel)); + }; + + const handleChildSelection = (panel: any, child: any) => { + setSelectedChild({ + [panel.label]: child, + }); + handleMenuClose(panel.label); + handlePanelClick(child.panel); + }; + const { title, subtitle, logo } = header || { title: 'PRISM', }; @@ -148,64 +180,40 @@ function NavBar() {
{panels.map(panel => { - const Wrap = - badgeContent >= 1 && panel.panel === Panel.Layers - ? // eslint-disable-next-line react/no-unused-prop-types - ({ children }: { children: React.ReactNode }) => ( - - {children} - - ) - : ({ children }: { children: React.ReactNode }) => children; + const selected = + tabValue === panel.panel || + (panel.children && + panel.children.some(child => tabValue === child.panel)); + + const buttonText = selectedChild[panel.label] + ? selectedChild[panel.label].label + : t(panel.label); return ( - {!smDown && ( - - )} - {!mdUp && ( - - { - dispatch(setTabValue(panel.panel)); - }} - > - {panel.icon} - - + { + if (panel.children) { + handleMenuOpen(panel.label, e); + } else if (panel.panel) { + handlePanelClick(panel.panel); + } + }} + isMobile={!mdUp} + buttonText={buttonText} + /> + {panel.children && ( + handleMenuClose(panel.label)} + handleChildClick={(child: any) => + handleChildSelection(panel, child) + } + selected={tabValue} + /> )} ); diff --git a/frontend/src/config/types.ts b/frontend/src/config/types.ts index b029086c2..5604c1cb8 100644 --- a/frontend/src/config/types.ts +++ b/frontend/src/config/types.ts @@ -8,6 +8,7 @@ import { } from 'maplibre-gl'; import { Dispatch } from 'redux'; import { TFunction } from 'i18next'; +import React from 'react'; import { rawLayers } from '.'; import type { ReportKey, TableKey } from './utils'; import type { PopupMetaData } from '../context/tooltipStateSlice'; @@ -739,6 +740,29 @@ export interface MenuItemType { layersCategories: LayersCategoryType[]; } +export type PanelItem = { + panel?: Panel; + label: string; + icon: React.ReactNode; + children?: PanelItem[]; +}; + +export enum Panel { + None = 'none', + Layers = 'layers', + Charts = 'charts', + Analysis = 'analysis', + Tables = 'tables', + AnticipatoryActionDrought = 'anticipatory_action_drougth', + AnticipatoryActionStorm = 'anticipatory_action_storm', + Alerts = 'alerts', +} + +export type LeftPanelState = { + tabValue: Panel; + panelSize: PanelSize; +}; + export type DateItem = { displayDate: number; // Date that will be rendered in the calendar. queryDate: number; // Date that will be used in the WMS request. @@ -904,8 +928,13 @@ export type MapEventWrapFunction = ( props: MapEventWrapFunctionProps, ) => (evt: MapLayerMouseEvent) => void; +export enum AnticipatoryAction { + storm = 'anticipatory_action_storm', + drought = 'anticipatory_action_drought', +} + export class AnticipatoryActionLayerProps extends CommonLayerProps { - type: 'anticipatory_action' = 'anticipatory_action'; + type: AnticipatoryAction = AnticipatoryAction.drought; @makeRequired declare title: string; diff --git a/frontend/src/config/utils.ts b/frontend/src/config/utils.ts index 0250ff1f7..fb50b4707 100644 --- a/frontend/src/config/utils.ts +++ b/frontend/src/config/utils.ts @@ -2,6 +2,7 @@ import { camelCase, get, map, mapKeys, isPlainObject, mapValues } from 'lodash'; import { appConfig, rawLayers, rawReports, rawTables } from '.'; import { AdminLevelDataLayerProps, + AnticipatoryAction, AnticipatoryActionLayerProps, BoundaryLayerProps, checkRequiredKeys, @@ -125,16 +126,20 @@ export const getLayerByKey = (layerKey: LayerKey): LayerType => { return throwInvalidLayer(); } return definition; - case 'anticipatory_action': - if (!checkRequiredKeys(AnticipatoryActionLayerProps, definition, true)) { - return throwInvalidLayer(); + case 'anticipatory_action_drought': + case 'anticipatory_action_storm': + if ( + checkRequiredKeys(CompositeLayerProps, definition, true) && + isAnticipatoryActionLayer(definition.type) + ) { + return definition; } - return definition; + return throwInvalidLayer(); default: // doesn't do anything, but it helps catch any layer type cases we forgot above compile time via TS. // https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript // eslint-disable-next-line no-unused-vars - ((_: never) => {})(definition.type); + ((_: never | AnticipatoryAction) => {})(definition.type); throw new Error( `Found invalid layer definition for layer '${layerKey}' (Unknown type '${definition.type}'). Check config/layers.json.`, ); @@ -158,27 +163,36 @@ function verifyValidImpactLayer( } export const AAWindowKeys = ['Window 1', 'Window 2'] as const; -export const AALayerId = 'anticipatory_action'; +export const AALayerIds = Object.values(AnticipatoryAction); export const LayerDefinitions: LayersMap = (() => { const aaUrl = appConfig.anticipatoryActionUrl; - const AALayer: AnticipatoryActionLayerProps = { - id: AALayerId, - title: 'Anticipatory Action', - type: 'anticipatory_action', - opacity: 0.9, - }; + const AALayers: AnticipatoryActionLayerProps[] = [ + { + id: AnticipatoryAction.drought, + title: 'Anticipatory Action Drought', + type: AnticipatoryAction.drought, + opacity: 0.9, + }, + { + id: AnticipatoryAction.storm, + title: 'Anticipatory Action Storm', + type: AnticipatoryAction.storm, + opacity: 0.9, + }, + ]; + + const AALayersById = AALayers.reduce( + (acc, layer) => ({ ...acc, [layer.id]: layer }), + {} as Record, + ); const layers = Object.keys(rawLayers).reduce( (acc, layerKey) => ({ ...acc, [layerKey]: getLayerByKey(layerKey as LayerKey), }), - (aaUrl - ? { - [AALayerId]: AALayer, - } - : {}) as LayersMap, + (aaUrl ? AALayersById : {}) as LayersMap, ); // Verify that the layers referenced by impact layers actually exist @@ -281,6 +295,11 @@ export function getWMSLayersWithChart(): WMSLayerProps[] { ) as WMSLayerProps[]; } +export const isAnticipatoryActionLayer = ( + type: string, +): type is AnticipatoryAction => + Object.values(AnticipatoryAction).includes(type as AnticipatoryAction); + export const areChartLayersAvailable = getWMSLayersWithChart().length > 0; const isValidReportsDefinition = ( diff --git a/frontend/src/context/leftPanelStateSlice.ts b/frontend/src/context/leftPanelStateSlice.ts index a9343086c..0aba59322 100644 --- a/frontend/src/context/leftPanelStateSlice.ts +++ b/frontend/src/context/leftPanelStateSlice.ts @@ -1,25 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { appConfig } from 'config'; -import { PanelSize } from 'config/types'; +import { LeftPanelState, Panel, PanelSize } from 'config/types'; import type { RootState } from './store'; const { hidePanel } = appConfig; -export enum Panel { - None = 'none', - Layers = 'layers', - Charts = 'charts', - Analysis = 'analysis', - Tables = 'tables', - AnticipatoryAction = 'anticipatory_action', - Alerts = 'alerts', -} - -type LeftPanelState = { - tabValue: Panel; - panelSize: PanelSize; -}; - const initialState: LeftPanelState = { tabValue: hidePanel ? Panel.None : Panel.Layers, panelSize: PanelSize.medium, diff --git a/frontend/src/context/mapStateSlice/index.ts b/frontend/src/context/mapStateSlice/index.ts index 8eb5ef1bc..3c26983b1 100644 --- a/frontend/src/context/mapStateSlice/index.ts +++ b/frontend/src/context/mapStateSlice/index.ts @@ -71,7 +71,8 @@ export const layerOrdering = (a: LayerType, b: LayerType) => { | 'point_data' | 'polygon' | 'static_raster' - | 'anticipatory_action']: number; + | 'anticipatory_action_drought' + | 'anticipatory_action_storm']: number; } = { point_data: 0, polygon: 1, @@ -82,7 +83,8 @@ export const layerOrdering = (a: LayerType, b: LayerType) => { composite: 5, wms: 6, static_raster: 7, - anticipatory_action: 8, + anticipatory_action_drought: 8, + anticipatory_action_storm: 9, }; const typeA = getTypeOrder(a); diff --git a/frontend/src/utils/keep-layer-utils.ts b/frontend/src/utils/keep-layer-utils.ts index 687da2ab2..18924ff99 100644 --- a/frontend/src/utils/keep-layer-utils.ts +++ b/frontend/src/utils/keep-layer-utils.ts @@ -1,5 +1,6 @@ import { LayerType, MenuGroup } from 'config/types'; import { menuList } from 'components/MapView/LeftPanel/utils'; +import { isAnticipatoryActionLayer } from 'config/utils'; // Layer types that are allowed to have multiple layers overlap on the map. export const TYPES_ALLOWED_TO_OVERLAP = [ @@ -37,7 +38,7 @@ export function keepLayer(layer: LayerType, newLayer: LayerType) { return false; } - if (newLayer.type === layer.type && layer.type === 'anticipatory_action') { + if (newLayer.type === layer.type && isAnticipatoryActionLayer(layer.type)) { return true; } diff --git a/frontend/src/utils/layers-utils.tsx b/frontend/src/utils/layers-utils.tsx index 8fe37057c..41ba0c538 100644 --- a/frontend/src/utils/layers-utils.tsx +++ b/frontend/src/utils/layers-utils.tsx @@ -4,11 +4,18 @@ import { Extent, expandBoundingBox, } from 'components/MapView/Layers/raster-utils'; -import { LayerKey, LayerType, isMainLayer, DateItem } from 'config/types'; import { - AALayerId, + LayerKey, + LayerType, + isMainLayer, + DateItem, + AnticipatoryAction, +} from 'config/types'; +import { + AALayerIds, LayerDefinitions, getBoundaryLayerSingleton, + isAnticipatoryActionLayer, } from 'config/utils'; import { addLayer, @@ -48,7 +55,8 @@ const dateSupportLayerTypes: Array = [ 'point_data', 'wms', 'static_raster', - 'anticipatory_action', + AnticipatoryAction.drought, + AnticipatoryAction.storm, ]; const useLayers = () => { @@ -91,8 +99,9 @@ const useLayers = () => { const numberOfActiveLayers = useMemo( () => - hazardLayersArray.filter(x => x !== AALayerId).length + - baselineLayersArray.length, + hazardLayersArray.filter( + x => !AALayerIds.includes(x as AnticipatoryAction), + ).length + baselineLayersArray.length, [baselineLayersArray.length, hazardLayersArray], ); @@ -156,7 +165,7 @@ const useLayers = () => { countBy( selectedLayersWithDateSupport .map(layer => { - if (layer.type === 'anticipatory_action') { + if (isAnticipatoryActionLayer(layer.type)) { // Combine dates for all AA windows to allow selecting AA for the whole period return AAAvailableDatesCombined; } @@ -187,7 +196,7 @@ const useLayers = () => { } const selectedNonAALayersWithDateSupport = selectedLayersWithDateSupport.filter( - layer => layer.type !== 'anticipatory_action', + layer => !isAnticipatoryActionLayer(layer.type), ); /* Only keep the dates which were duplicated the same amount of times as the amount of layers active...and convert back to array. @@ -438,7 +447,7 @@ const useLayers = () => { const possibleDatesForLayerIncludeSelectedDate = useCallback( (layer: DateCompatibleLayer, date: Date) => binaryIncludes( - layer.type === 'anticipatory_action' + isAnticipatoryActionLayer(layer.type) ? AAAvailableDatesCombined : getPossibleDatesForLayer(layer, serverAvailableDates), date.setUTCHours(12, 0, 0, 0), @@ -457,7 +466,7 @@ const useLayers = () => { const jsSelectedDate = new Date(providedSelectedDate); const AADatesLoaded = - layer.type !== 'anticipatory_action' || + !isAnticipatoryActionLayer(layer.type) || layer.id in serverAvailableDates; if ( diff --git a/frontend/src/utils/server-utils.ts b/frontend/src/utils/server-utils.ts index 54d0c9d52..a6523d7ae 100644 --- a/frontend/src/utils/server-utils.ts +++ b/frontend/src/utils/server-utils.ts @@ -149,7 +149,8 @@ export const getPossibleDatesForLayer = ( date => date.displayDate > startDateTimestamp, ); } - case 'anticipatory_action': + case 'anticipatory_action_drought': + case 'anticipatory_action_storm': return serverAvailableDates[layer.id] || []; default: return [];