From 7e7cd869ddec9b3b13844b3cfd0f66d93c1f047b Mon Sep 17 00:00:00 2001 From: Will Gislason <8203830+gislawill@users.noreply.github.com> Date: Mon, 29 Jul 2024 12:07:45 -0700 Subject: [PATCH] Adding support for seasonal cdi; COUNTRY=jordan (#1301) * Updating to use month range * Fix date propagation in CDI * Update composite_data.ts * Use getFormattedDate * Update composite_data.ts * Update composite_data.ts * add mozambique cdi for testing * Update index.tsx * Dynamic error cdi; COUNTRY=jordan (#1259) * Updating to use local hip service * no console * Clean up * Clean up readme --------- Co-authored-by: Christopher J Lowrie Co-authored-by: ericboucher Co-authored-by: Amit Wadhwa Co-authored-by: Will Gislason <8203830+gislawill@users.noreply.github.com> * Removing local hip service env var * Error handling fixes and end date support form the lyer * Initial support for seasonal CDI * Remove console log, update configs for tests to pass * Appease eslint * Fixing seasonal fetch - using first of next season as 'end' * Support for validity areas in composite layers * Reducing date deduping * Clean up * refactor datesAreEqualWithoutTime * Fix datesAreEqualWithoutTime * Adding 'season' validity * Skip the tests * Fixing query date for seasonal range * Updating CDI config in cambodia and mozambique * Update tests * undo log update * update mozambique CDI for testing * Update composite monthly date range * Handling overlap selection for dates without overlapping validity * fix test * rename scale to period * Updates to date picking * Allow composite layers with validity or without * Update the query display matching to be more flexible * Updating event handlers in time selection * Test fic * Better test fix * Break out visible layer dates and selectable layer dates * don't fetch CDI for invalid dates * fix tests * Fixing availability date indicator * Ensuring messaging fires * fix lint * Reuse clickDate for draggable item * Update index.tsx * Update datasetStateSlice.ts * Update index.tsx * Adding back no date overlapping handling * Ensuring that date selection warning is displayed when the date jumps to valid date * Remove check intersection, leveraging logic in layer-utils * using in datesAreEqualWithoutTime in findDateIndex * Update layers-utils.tsx * Only remove non-boundary layers * Handling AA dates with fewer limitations * Add comment to code * Fix extra visible dates for AA * Update logic in useLayer's useEffect to ensure we don't exit early * Fix AA parseFloat * Ensuring group is writable in formatLayersCategories * fixing eslint --------- Co-authored-by: Christopher J Lowrie Co-authored-by: ericboucher Co-authored-by: Amit Wadhwa Co-authored-by: Chris Lowrie --- .../TimelineItems/TimelineItem/index.tsx | 39 ++- .../DateSelector/TimelineItems/index.test.tsx | 3 +- .../DateSelector/TimelineItems/index.tsx | 89 +---- .../components/MapView/DateSelector/index.tsx | 309 +++++++++++++----- .../components/MapView/DateSelector/utils.ts | 3 +- .../MapView/Layers/CompositeLayer/index.tsx | 25 +- .../AnticipatoryActionPanel/Forecast/utils.ts | 9 +- .../MenuItem/MenuSwitch/SwitchItems.tsx | 7 +- .../layersPanel/RootAccordionItems/index.tsx | 9 +- .../src/components/MapView/LeftPanel/utils.ts | 46 ++- .../MapView/OtherFeatures/index.tsx | 4 +- frontend/src/config/jordan/layers.json | 214 +++++++++++- frontend/src/config/jordan/prism.json | 33 +- frontend/src/config/mozambique/layers.json | 14 +- frontend/src/config/mozambique/prism.json | 6 +- frontend/src/config/types.ts | 4 +- .../anticipatoryActionStateSlice/utils.ts | 4 - frontend/src/context/datasetStateSlice.ts | 5 +- frontend/src/context/layers/composite_data.ts | 19 +- frontend/src/utils/date-utils.ts | 34 +- frontend/src/utils/layers-utils.tsx | 175 +++++----- frontend/src/utils/server-utils.test.ts | 24 ++ frontend/src/utils/server-utils.ts | 110 +++++-- 23 files changed, 853 insertions(+), 332 deletions(-) diff --git a/frontend/src/components/MapView/DateSelector/TimelineItems/TimelineItem/index.tsx b/frontend/src/components/MapView/DateSelector/TimelineItems/TimelineItem/index.tsx index 17347eb23..2970f95dc 100644 --- a/frontend/src/components/MapView/DateSelector/TimelineItems/TimelineItem/index.tsx +++ b/frontend/src/components/MapView/DateSelector/TimelineItems/TimelineItem/index.tsx @@ -1,8 +1,8 @@ import { createStyles, makeStyles } from '@material-ui/core'; -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import 'react-datepicker/dist/react-datepicker.css'; import { DateItem, DateRangeType } from 'config/types'; -import { binaryFind } from 'utils/date-utils'; +import { datesAreEqualWithoutTime } from 'utils/date-utils'; const TimelineItem = memo( ({ @@ -13,14 +13,35 @@ const TimelineItem = memo( }: TimelineItemProps) => { // Pre-compute the matching indices for all layers const classes = useStyles(); - const layerMatches = concatenatedLayers.map(layerDates => - binaryFind( - layerDates, - new Date(currentDate.value).setUTCHours(0, 0, 0, 0), - (i: DateItem) => new Date(i.displayDate).setUTCHours(0, 0, 0, 0), - ), + + const displayDateMatches = useMemo( + () => + concatenatedLayers.map(layerDates => + layerDates.findIndex(i => + datesAreEqualWithoutTime(i.displayDate, currentDate.value), + ), + ), + [concatenatedLayers, currentDate.value], ); + const layerMatches = useMemo(() => { + const queryDateMatches = concatenatedLayers.map(layerDates => + layerDates.findIndex(i => + datesAreEqualWithoutTime(i.queryDate, currentDate.value), + ), + ); + + return displayDateMatches.map((displayDateMatch, layerIndex) => + queryDateMatches[layerIndex] > -1 && + !datesAreEqualWithoutTime( + concatenatedLayers[layerIndex][displayDateMatch].queryDate, + currentDate.value, + ) + ? queryDateMatches[layerIndex] + : displayDateMatch, + ); + }, [concatenatedLayers, currentDate.value, displayDateMatches]); + const hasNextItemDirectionForward = ( _matchingDate: DateItem, _layerDates: DateItem[], @@ -32,7 +53,7 @@ const TimelineItem = memo( ): boolean => false; const isQueryDate = (date: DateItem): boolean => - date.queryDate === date.displayDate; + datesAreEqualWithoutTime(date.queryDate, date.displayDate); return ( <> diff --git a/frontend/src/components/MapView/DateSelector/TimelineItems/index.test.tsx b/frontend/src/components/MapView/DateSelector/TimelineItems/index.test.tsx index f549edbfd..7fab6cda3 100644 --- a/frontend/src/components/MapView/DateSelector/TimelineItems/index.test.tsx +++ b/frontend/src/components/MapView/DateSelector/TimelineItems/index.test.tsx @@ -4,7 +4,8 @@ import { Provider } from 'react-redux'; import TimelineItems from '.'; const props = { - selectedLayers: [], + orderedLayers: [], + truncatedLayers: [], selectedLayerTitles: [], availableDates: [], dateRange: [ diff --git a/frontend/src/components/MapView/DateSelector/TimelineItems/index.tsx b/frontend/src/components/MapView/DateSelector/TimelineItems/index.tsx index c327ac788..9a76baca3 100644 --- a/frontend/src/components/MapView/DateSelector/TimelineItems/index.tsx +++ b/frontend/src/components/MapView/DateSelector/TimelineItems/index.tsx @@ -15,10 +15,6 @@ import { TIMELINE_ITEM_WIDTH, } from 'components/MapView/DateSelector/utils'; import { datesAreEqualWithoutTime, getFormattedDate } from 'utils/date-utils'; -import { useSelector } from 'react-redux'; -import { AAAvailableDatesSelector } from 'context/anticipatoryActionStateSlice'; -import { dateRangeSelector } from 'context/mapStateSlice/selectors'; -import { getRequestDate } from 'utils/server-utils'; import TimelineItem from './TimelineItem'; import TimelineLabel from './TimelineLabel'; import TooltipItem from './TooltipItem'; @@ -43,54 +39,15 @@ const TimelineItems = memo( dateRange, clickDate, locale, - selectedLayers, + orderedLayers, + truncatedLayers, availableDates, }: TimelineItemsProps) => { const classes = useStyles(); const { t } = useSafeTranslation(); - const AAAvailableDates = useSelector(AAAvailableDatesSelector); - - // Create a temporary layer for each AA window - const AALayers = AAAvailableDates - ? [ - { - id: 'anticipatory_action_window_1', - title: 'Window 1', - dateItems: AAAvailableDates['Window 1'], - }, - { - id: 'anticipatory_action_window_2', - title: 'Window 2', - dateItems: AAAvailableDates['Window 2'], - }, - ] - : []; - - // Replace anticipatory action unique layer by window1 and window2 layers - // Keep anticipatory actions at the top of the timeline - // eslint-disable-next-line fp/no-mutating-methods - const orderedLayers = selectedLayers - .sort((a, b) => { - const aIsAnticipatory = a.id.includes('anticipatory_action'); - const bIsAnticipatory = b.id.includes('anticipatory_action'); - if (aIsAnticipatory && !bIsAnticipatory) { - return -1; - } - if (!aIsAnticipatory && bIsAnticipatory) { - return 1; - } - return 0; - }) - .map(l => { - if (l.type === 'anticipatory_action') { - return AALayers; - } - return l; - }) - .flat(); // Hard coded styling for date items (first, second, and third layers) - const DATE_ITEM_STYLING: DateItemStyle[] = React.useMemo( + const DATE_ITEM_STYLING: DateItemStyle[] = useMemo( () => [ { class: classes.layerOneDate, @@ -151,43 +108,6 @@ const TimelineItems = memo( [DATE_ITEM_STYLING, orderedLayers, t], ); - const timelineStartDate: string = new Date( - dateRange[0].value, - ).toDateString(); - - const dateSelector = useSelector(dateRangeSelector); - // We truncate layer by removing date that will not be drawn to the Timeline - const truncatedLayers: DateItem[][] = useMemo(() => { - // returns the index of the first date in layer that matches the first Timeline date - const findLayerFirstDateIndex = (items: DateItem[]): number => - items - .map(d => new Date(d.displayDate).toDateString()) - .indexOf(timelineStartDate); - - return [ - ...orderedLayers.map(layer => { - const firstIndex = findLayerFirstDateIndex(layer.dateItems); - const layerQueryDate = getRequestDate( - layer.dateItems, - dateSelector.startDate, - ); - // Filter date items based on queryDate and layerQueryDate - const filterDateItems = (items: DateItem[]) => - items.filter( - item => - item.queryDate === layerQueryDate || - item.queryDate === item.displayDate, - ); - if (firstIndex === -1) { - return filterDateItems(layer.dateItems); - } - // truncate the date item array at index matching timeline first date - // eslint-disable-next-line fp/no-mutating-methods - return filterDateItems(layer.dateItems.slice(firstIndex)); - }), - ]; - }, [orderedLayers, timelineStartDate, dateSelector.startDate]); - const availableDatesToDisplay = availableDates.filter( date => date >= dateRange[0].value, ); @@ -330,8 +250,9 @@ export interface TimelineItemsProps { dateRange: DateRangeType[]; clickDate: (arg: number) => void; locale: string; - selectedLayers: DateCompatibleLayerWithDateItems[]; availableDates: number[]; + orderedLayers: DateCompatibleLayerWithDateItems[]; + truncatedLayers: DateItem[][]; } export default TimelineItems; diff --git a/frontend/src/components/MapView/DateSelector/index.tsx b/frontend/src/components/MapView/DateSelector/index.tsx index 93e2a3e01..3ef66dd32 100644 --- a/frontend/src/components/MapView/DateSelector/index.tsx +++ b/frontend/src/components/MapView/DateSelector/index.tsx @@ -14,9 +14,8 @@ 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 { DateRangeType } from 'config/types'; +import { DateItem, DateRangeType } from 'config/types'; import { dateRangeSelector } from 'context/mapStateSlice/selectors'; -import { addNotification } from 'context/notificationStateSlice'; import { locales, useSafeTranslation } from 'i18n'; import { dateStrToUpperCase, @@ -29,10 +28,16 @@ import useLayers from 'utils/layers-utils'; import { format } from 'date-fns'; import { Panel, leftPanelTabValueSelector } from 'context/leftPanelStateSlice'; import { updateDateRange } from 'context/mapStateSlice'; +import { getRequestDate } from 'utils/server-utils'; +import { AAAvailableDatesSelector } from 'context/anticipatoryActionStateSlice'; import TickSvg from './tick.svg'; import DateSelectorInput from './DateSelectorInput'; import TimelineItems from './TimelineItems'; -import { TIMELINE_ITEM_WIDTH, findDateIndex } from './utils'; +import { + DateCompatibleLayerWithDateItems, + TIMELINE_ITEM_WIDTH, + findDateIndex, +} from './utils'; import { oneDayInMs } from '../LeftPanel/utils'; type Point = { @@ -59,6 +64,7 @@ const DateSelector = memo(() => { const { selectedLayerDates: availableDates, selectedLayersWithDateSupport: selectedLayers, + checkSelectedDateForLayerSupport, } = useLayers(); const { startDate: stateStartDate } = useSelector(dateRangeSelector); const tabValue = useSelector(leftPanelTabValueSelector); @@ -92,11 +98,117 @@ const DateSelector = memo(() => { const smUp = useMediaQuery(theme.breakpoints.up('sm')); const xsDown = useMediaQuery(theme.breakpoints.down('xs')); + useEffect(() => { + const closestDate = checkSelectedDateForLayerSupport(stateStartDate); + if (closestDate) { + updateStartDate(new Date(closestDate), true); + } + // Only run this check when selectedLayers changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedLayers]); + const maxDate = useMemo( () => new Date(Math.max(...availableDates, new Date().getTime())), [availableDates], ); + const AAAvailableDates = useSelector(AAAvailableDatesSelector); + + // Create a temporary layer for each AA window + const AALayers: DateCompatibleLayerWithDateItems[] = useMemo( + () => + AAAvailableDates + ? [ + { + id: 'anticipatory_action_window_1', + title: 'Window 1', + dateItems: AAAvailableDates['Window 1'], + type: 'anticipatory_action', + opacity: 1, + }, + { + id: 'anticipatory_action_window_2', + title: 'Window 2', + dateItems: AAAvailableDates['Window 2'], + type: 'anticipatory_action', + opacity: 1, + }, + ] + : [], + [AAAvailableDates], + ); + + // Replace anticipatory action unique layer by window1 and window2 layers + // Keep anticipatory actions at the top of the timeline + const orderedLayers: DateCompatibleLayerWithDateItems[] = useMemo( + () => + // eslint-disable-next-line fp/no-mutating-methods + selectedLayers + .sort((a, b) => { + const aIsAnticipatory = a.id.includes('anticipatory_action'); + const bIsAnticipatory = b.id.includes('anticipatory_action'); + if (aIsAnticipatory && !bIsAnticipatory) { + return -1; + } + if (!aIsAnticipatory && bIsAnticipatory) { + return 1; + } + return 0; + }) + .map(l => (l.type === 'anticipatory_action' ? AALayers : l)) + .flat(), + [selectedLayers, AALayers], + ); + + const timelineStartDate: string = useMemo( + () => new Date(dateRange[0].value).toDateString(), + [dateRange], + ); + + const dateSelector = useSelector(dateRangeSelector); + + // We truncate layer by removing date that will not be drawn to the Timeline + const truncatedLayers: DateItem[][] = useMemo(() => { + // returns the index of the first date in layer that matches the first Timeline date + const findLayerFirstDateIndex = (items: DateItem[]): number => + items + .map(d => new Date(d.displayDate).toDateString()) + .indexOf(timelineStartDate); + + return [ + ...orderedLayers.map(layer => { + const firstIndex = findLayerFirstDateIndex(layer.dateItems); + if (firstIndex === -1) { + return layer.dateItems; + } + // truncate the date item array at index matching timeline first date + // eslint-disable-next-line fp/no-mutating-methods + return layer.dateItems.slice(firstIndex); + }), + ]; + }, [orderedLayers, timelineStartDate]); + + const visibleLayers = useMemo( + () => + truncatedLayers.map((layer, index) => { + const layerQueryDate = getRequestDate( + layer, + dateSelector.startDate, + // Do not default to most recent for anticpatory action layers. + // TODO - what about other layers? + !orderedLayers[index].id.includes('anticipatory_action'), + ); + // Filter date items based on queryDate and layerQueryDate + return layer.filter( + item => + (layerQueryDate && + datesAreEqualWithoutTime(item.queryDate, layerQueryDate)) || + datesAreEqualWithoutTime(item.queryDate, item.displayDate), + ); + }), + [orderedLayers, truncatedLayers, dateSelector.startDate], + ); + const timeLineWidth = get(timeLine.current, 'offsetWidth', 0); const setPointerXPosition = useCallback(() => { @@ -193,23 +305,88 @@ const DateSelector = memo(() => { }); }, [dateIndex, range]); + // move pointer to closest date when change map layer + useEffect(() => { + if (isEqual(dateRef.current, availableDates)) { + return; + } + setDatePosition(stateStartDate, 0, false); + dateRef.current = availableDates; + }); + + const includedDates = useMemo( + () => availableDates?.map(d => new Date(d)) ?? [], + [availableDates], + ); + + // Find the dates that are queriable + const selectableDates = useMemo(() => { + if (truncatedLayers.length === 0) { + return []; + } + // Get the dates that are queriable for any layers + const dates = truncatedLayers.map(layerDates => + layerDates.map(dateItem => dateItem.displayDate), + ); + + // All dates in AA windows should be selectable, regardless of overlap + if (panelTab === Panel.AnticipatoryAction && AAAvailableDates) { + // eslint-disable-next-line fp/no-mutating-methods + dates.push( + AAAvailableDates?.['Window 1']?.map(d => d.displayDate) ?? [], + AAAvailableDates?.['Window 2']?.map(d => d.displayDate) ?? [], + ); + + // eslint-disable-next-line fp/no-mutating-methods + return dates + .reduce((acc, currentArray) => [ + ...acc, + ...currentArray.filter( + date => + !acc.some(accDate => datesAreEqualWithoutTime(date, accDate)), + ), + ]) + .sort((a, b) => a - b); + } + + // Other layers should rely on the dates available in truncatedLayers + return dates.reduce((acc, currentArray) => + acc.filter(date => + currentArray.some(currentDate => + datesAreEqualWithoutTime(date, currentDate), + ), + ), + ); + }, [AAAvailableDates, panelTab, truncatedLayers]); + const updateStartDate = useCallback( (date: Date, isUpdatingHistory: boolean) => { if (!isUpdatingHistory) { return; } const time = date.getTime(); - const startDate = new Date(stateStartDate as number); - const dateEqualsToStartDate = datesAreEqualWithoutTime(date, startDate); - if (dateEqualsToStartDate) { + const selectedIndex = findDateIndex(selectableDates, date.getTime()); + checkSelectedDateForLayerSupport(date.getTime()); + if ( + selectedIndex < 0 || + (stateStartDate && + datesAreEqualWithoutTime( + selectableDates[selectedIndex], + stateStartDate, + )) + ) { return; } - // This updates state because a useEffect in MapView updates the redux state - // TODO this is convoluted coupling, we should update state here if feasible. updateHistory('date', getFormattedDate(time, 'default') as string); dispatch(updateDateRange({ startDate: time })); }, - [stateStartDate, updateHistory, dispatch], + [ + selectableDates, + checkSelectedDateForLayerSupport, + stateStartDate, + updateHistory, + dispatch, + ], ); const setDatePosition = useCallback( @@ -229,15 +406,6 @@ const DateSelector = memo(() => { [availableDates, updateStartDate], ); - // move pointer to closest date when change map layer - useEffect(() => { - if (isEqual(dateRef.current, availableDates)) { - return; - } - setDatePosition(stateStartDate, 0, false); - dateRef.current = availableDates; - }); - const incrementDate = useCallback(() => { setDatePosition(stateStartDate, 1, true); }, [setDatePosition, stateStartDate]); @@ -246,52 +414,39 @@ const DateSelector = memo(() => { setDatePosition(stateStartDate, -1, true); }, [setDatePosition, stateStartDate]); - const includedDates = useMemo( - () => availableDates?.map(d => new Date(d)) ?? [], - [availableDates], - ); - - const checkIntersectingDateAndShowPopup = useCallback( - (selectedDate: Date, positionY: number) => { - const findDateInIntersectingDates = includedDates.find(date => - datesAreEqualWithoutTime(date, selectedDate), + const clickDate = useCallback( + (index: number) => { + const selectedIndex = findDateIndex( + selectableDates, + dateRange[index].value, ); - if (findDateInIntersectingDates) { + const inRangeDate = new Date(dateRange[index].value); + + updateStartDate(inRangeDate, true); + + if ( + selectedIndex < 0 || + (stateStartDate && + datesAreEqualWithoutTime( + selectableDates[selectedIndex], + stateStartDate, + )) + ) { return; } - // if the date is not an intersecting one default to last intersecting date - setPointerPosition({ - x: dateIndex * TIMELINE_ITEM_WIDTH, - y: positionY, - }); - dispatch( - addNotification({ - message: t( - 'The date you selected is not valid for all selected layers. To change the date, either select a date where all selected layers have data (see timeline ticks), or deselect a layer', - ), - type: 'warning', - }), - ); + setPointerPosition({ x: index * TIMELINE_ITEM_WIDTH, y: 0 }); + const updatedDate = new Date(selectableDates[selectedIndex]); + updateStartDate(updatedDate, true); }, - [dateIndex, dispatch, includedDates, t], + [ + selectableDates, + dateRange, + stateStartDate, + setPointerPosition, + updateStartDate, + ], ); - // Click on available date to move the pointer - const clickDate = (index: number) => { - const selectedIndex = findDateIndex(availableDates, dateRange[index].value); - if ( - selectedIndex < 0 || - (stateStartDate && - datesAreEqualWithoutTime(availableDates[selectedIndex], stateStartDate)) - ) { - return; - } - setPointerPosition({ x: index * TIMELINE_ITEM_WIDTH, y: 0 }); - const updatedDate = new Date(availableDates[selectedIndex]); - checkIntersectingDateAndShowPopup(new Date(dateRange[index].value), 0); - updateStartDate(updatedDate, true); - }; - // Set timeline position after being dragged const onTimelineStop = useCallback((_e: DraggableEvent, position: Point) => { setTimelinePosition(position); @@ -345,27 +500,7 @@ const DateSelector = memo(() => { if (exactX >= dateRange.length) { return; } - const selectedIndex = findDateIndex( - availableDates, - dateRange[exactX].value, - ); - if ( - selectedIndex < 0 || - availableDates[selectedIndex] === stateStartDate - ) { - return; - } - setPointerPosition({ - x: exactX * TIMELINE_ITEM_WIDTH, - y: position.y, - }); - const updatedDate = new Date(availableDates[selectedIndex]); - checkIntersectingDateAndShowPopup( - new Date(dateRange[exactX].value), - position.y, - ); - updateStartDate(updatedDate, true); - + clickDate(exactX); // Hide the tooltip for exactX const tooltipElement = document.querySelector( `[data-date-index="${exactX}"]`, @@ -376,13 +511,7 @@ const DateSelector = memo(() => { ); } }, - [ - availableDates, - checkIntersectingDateAndShowPopup, - dateRange, - stateStartDate, - updateStartDate, - ], + [clickDate, dateRange], ); const handleOnDatePickerChange = useCallback( @@ -392,6 +521,11 @@ const DateSelector = memo(() => { [updateStartDate], ); + // Only display the date selector once dates are loaded + if (dateRange.length <= 1) { + return null; + } + return (
{ dateRange={dateRange} clickDate={clickDate} locale={locale} - selectedLayers={selectedLayers} + orderedLayers={orderedLayers} + truncatedLayers={visibleLayers} availableDates={availableDates} /> )} diff --git a/frontend/src/components/MapView/DateSelector/utils.ts b/frontend/src/components/MapView/DateSelector/utils.ts index 5d2915e08..0092b2c8f 100644 --- a/frontend/src/components/MapView/DateSelector/utils.ts +++ b/frontend/src/components/MapView/DateSelector/utils.ts @@ -1,5 +1,6 @@ import { DateCompatibleLayer } from 'utils/server-utils'; import { DateItem } from 'config/types'; +import { datesAreEqualWithoutTime } from 'utils/date-utils'; export const TIMELINE_ITEM_WIDTH = 4; @@ -56,7 +57,7 @@ export function findDateIndex( let endIndex = availableDates.length - 1; while (startIndex <= endIndex) { const midIndex = Math.floor((startIndex + endIndex) / 2); - if (availableDates[midIndex] === date) { + if (datesAreEqualWithoutTime(availableDates[midIndex], date)) { return midIndex; } if (midIndex === startIndex && endIndex - startIndex <= 1) { diff --git a/frontend/src/components/MapView/Layers/CompositeLayer/index.tsx b/frontend/src/components/MapView/Layers/CompositeLayer/index.tsx index 43c445270..cf6a44a04 100644 --- a/frontend/src/components/MapView/Layers/CompositeLayer/index.tsx +++ b/frontend/src/components/MapView/Layers/CompositeLayer/index.tsx @@ -1,7 +1,7 @@ import { CompositeLayerProps, LegendDefinition } from 'config/types'; import { LayerData, loadLayerData } from 'context/layers/layer-data'; import { layerDataSelector } from 'context/mapStateSlice/selectors'; -import { memo, useEffect, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Source, Layer } from 'react-map-gl/maplibre'; import { getLayerMapId } from 'utils/map-utils'; @@ -10,7 +10,7 @@ import { Point } from 'geojson'; import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; import { availableDatesSelector } from 'context/serverStateSlice'; import { useDefaultDate } from 'utils/useDefaultDate'; -import { getRequestDate } from 'utils/server-utils'; +import { getRequestDateItem } from 'utils/server-utils'; import { safeCountry } from 'config'; import { geoToH3, h3ToGeoBoundary } from 'h3-js'; // ts-ignore import { opacitySelector } from 'context/opacityStateSlice'; @@ -45,12 +45,17 @@ const CompositeLayer = memo(({ layer, before }: Props) => { const opacityState = useSelector(opacitySelector(layer.id)); const dispatch = useDispatch(); - const layerAvailableDates = serverAvailableDates[layer.dateLayer]; - const queryDate = getRequestDate(layerAvailableDates, selectedDate); + const layerAvailableDates = + serverAvailableDates[layer.id] || serverAvailableDates[layer.dateLayer]; + const queryDateItem = useMemo( + () => getRequestDateItem(layerAvailableDates, selectedDate, false), + [layerAvailableDates, selectedDate], + ); + const requestDate = queryDateItem?.startDate || queryDateItem?.queryDate; const { data } = (useSelector( - layerDataSelector(layer.id, queryDate), + layerDataSelector(layer.id, requestDate), ) as LayerData) || {}; useEffect(() => { @@ -63,8 +68,10 @@ const CompositeLayer = memo(({ layer, before }: Props) => { }, []); useEffect(() => { - dispatch(loadLayerData({ layer, date: queryDate })); - }, [dispatch, layer, queryDate]); + if (requestDate) { + dispatch(loadLayerData({ layer, date: requestDate })); + } + }, [dispatch, layer, requestDate]); // Investigate performance impact of hexagons for large countries const finalFeatures = @@ -103,9 +110,9 @@ const CompositeLayer = memo(({ layer, before }: Props) => { features: finalFeatures, }; return ( - + { const val = indexes.map(index => { - const indexData = catData.filter(x => x.index === index); + const indexData = catData.filter( + x => x.index === index && !x.computedRow, + ); if (indexData.length > 0) { // Sort by date in descending order to get the latest date first // eslint-disable-next-line fp/no-mutating-methods indexData.sort((a, b) => b.date.localeCompare(a.date)); // Take the probability of the first element (latest date) - const latest = Math.trunc(indexData[0].probability * 100); + const latest = parseFloat( + ((indexData[0].probability || 0) * 100).toFixed(2), + ); + const showWarningSign = Boolean(indexData[0].isValid); return [index, { probability: latest, showWarningSign }]; } diff --git a/frontend/src/components/MapView/LeftPanel/layersPanel/MenuItem/MenuSwitch/SwitchItems.tsx b/frontend/src/components/MapView/LeftPanel/layersPanel/MenuItem/MenuSwitch/SwitchItems.tsx index a2ee49b0a..a0eb44cb5 100644 --- a/frontend/src/components/MapView/LeftPanel/layersPanel/MenuItem/MenuSwitch/SwitchItems.tsx +++ b/frontend/src/components/MapView/LeftPanel/layersPanel/MenuItem/MenuSwitch/SwitchItems.tsx @@ -3,6 +3,7 @@ import { getCompositeLayers } from 'config/utils'; import { Fragment, memo } from 'react'; import { Extent } from 'components/MapView/Layers/raster-utils'; import { createStyles, makeStyles } from '@material-ui/core'; +import useLayers from 'utils/layers-utils'; import SwitchItem from './SwitchItem'; const useStyles = makeStyles(() => @@ -23,12 +24,16 @@ interface SwitchItemsProps { extent?: Extent; } const SwitchItems = memo(({ layers, extent }: SwitchItemsProps) => { + const { selectedLayers } = useLayers(); const classes = useStyles(); return ( <> {layers.map((layer: LayerType) => { const foundNotRenderedLayer = layer.group?.layers.find( - layerItem => layerItem.id === layer.id && !layerItem.main, + layerItem => + layerItem.id === layer.id && + !layerItem.main && + !selectedLayers.some(sl => sl.id === layerItem.id), ); if (layer.group && foundNotRenderedLayer) { return null; diff --git a/frontend/src/components/MapView/LeftPanel/layersPanel/RootAccordionItems/index.tsx b/frontend/src/components/MapView/LeftPanel/layersPanel/RootAccordionItems/index.tsx index f4286e12b..f17610a89 100644 --- a/frontend/src/components/MapView/LeftPanel/layersPanel/RootAccordionItems/index.tsx +++ b/frontend/src/components/MapView/LeftPanel/layersPanel/RootAccordionItems/index.tsx @@ -1,11 +1,16 @@ import { LayersCategoryType, MenuItemType } from 'config/types'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import useLayers from 'utils/layers-utils'; import MenuItem from '../MenuItem'; -import { menuList } from '../../utils'; +import { getDynamicMenuList } from '../../utils'; const RootAccordionItems = memo(() => { const { adminBoundariesExtent: extent } = useLayers(); + const { selectedLayers } = useLayers(); + const menuList = useMemo( + () => getDynamicMenuList(selectedLayers), + [selectedLayers], + ); const layersMenuItems = menuList.filter((menuItem: MenuItemType) => menuItem.layersCategories.some( diff --git a/frontend/src/components/MapView/LeftPanel/utils.ts b/frontend/src/components/MapView/LeftPanel/utils.ts index 8e71a741a..6a550d62b 100644 --- a/frontend/src/components/MapView/LeftPanel/utils.ts +++ b/frontend/src/components/MapView/LeftPanel/utils.ts @@ -10,6 +10,7 @@ import { isLayerKey, LayerKey, LayersCategoryType, + LayerType, MenuGroup, MenuItemType, } from 'config/types'; @@ -19,9 +20,12 @@ type LayersCategoriesType = LayersCategoryType[]; type MenuItemsType = MenuItemType[]; -function formatLayersCategories(layersList: { - [key: string]: Array; -}): LayersCategoriesType { +function formatLayersCategories( + layersList: { + [key: string]: Array; + }, + selectedLayers?: LayerType[], +): LayersCategoriesType { return map(layersList, (layerKeys, layersListKey) => ({ title: startCase(layersListKey), layers: layerKeys.filter(isLayerKey).map(key => { @@ -29,10 +33,23 @@ function formatLayersCategories(layersList: { const group = mapKeys(key, (_v, k: string) => camelCase(k), ) as unknown as MenuGroup; - const mainLayer = group.layers.find(l => l.main); + const mainLayer = + group.layers.find(gl => + selectedLayers?.some(sl => sl.id === gl.id), + ) || group.layers.find(l => l.main); + const layer = LayerDefinitions[mainLayer?.id as LayerKey]; - // eslint-disable-next-line fp/no-mutation - layer.group = group; + + // Check if layer is frozen or sealed before writing to it, required to prevent a race condition + if (Object.isFrozen(layer)) { + console.error(`Layer ${layer?.id} is frozen and cannot be modified.`); + } else if (Object.isSealed(layer)) { + console.error(`Layer ${layer?.id} is sealed and cannot be modified.`); + } else { + // eslint-disable-next-line fp/no-mutation + layer.group = group; + } + return layer; } return LayerDefinitions[key as LayerKey]; @@ -76,6 +93,23 @@ export const menuList: MenuItemsType = map( }, ); +export const getDynamicMenuList = (selectedLayers?: LayerType[]) => + map(appConfig.categories, (layersCategories, categoryKey) => { + if (!checkLayersCategories(layersCategories)) { + throw new Error( + `'${categoryKey}' in prism.json isn't a valid category. Check console for more details.`, + ); + } + return { + title: startCase(categoryKey), + icon: get(appConfig, `icons.${categoryKey}`, 'icon_vulnerable.png'), + layersCategories: formatLayersCategories( + layersCategories, + selectedLayers, + ), + }; + }); + export const tablesMenuItems = menuList.filter((menuItem: MenuItemType) => menuItem.layersCategories.some( (layerCategory: LayersCategoryType) => layerCategory.tables.length > 0, diff --git a/frontend/src/components/MapView/OtherFeatures/index.tsx b/frontend/src/components/MapView/OtherFeatures/index.tsx index e75e1f3a3..52b6e35ca 100644 --- a/frontend/src/components/MapView/OtherFeatures/index.tsx +++ b/frontend/src/components/MapView/OtherFeatures/index.tsx @@ -22,7 +22,7 @@ const useStyles = makeStyles(() => ); const OtherFeatures = memo(() => { - const { selectedLayerDates } = useLayers(); + const { selectedLayersWithDateSupport } = useLayers(); const classes = useStyles(); const showBoundaryInfo = useMemo( @@ -33,7 +33,7 @@ const OtherFeatures = memo(() => { return ( - {selectedLayerDates.length > 0 && } + {selectedLayersWithDateSupport.length > 0 && } {showBoundaryInfo && } diff --git a/frontend/src/config/jordan/layers.json b/frontend/src/config/jordan/layers.json index 7a0a1a7b3..47a2619f4 100644 --- a/frontend/src/config/jordan/layers.json +++ b/frontend/src/config/jordan/layers.json @@ -4015,10 +4015,16 @@ ], "legend_text": "Groundwater developed for irrigation (MCM) " }, - "cdi_v1": { - "title": "Combined Drought Index (v1)", + "cdi_v1_monthly": { + "title": "Monthly Combined Drought Index (v1)", "type": "composite", + "period": "monthly", "base_url": "https://hip-service.ovio.org/q_multi_geojson", + "validity": { + "forward": 1, + "backward": 2, + "mode": "dekad" + }, "input_layers": [ { "id": "spi_1m", @@ -4108,10 +4114,113 @@ "end_date": "2021-07-31", "opacity": 0.7 }, - "cdi_v2": { - "title": "Combined Drought Index (v2)", + "cdi_v1_seasonal": { + "title": "Seasonal Combined Drought Index (v1)", + "type": "composite", + "period": "seasonal", + "base_url": "https://hip-service.ovio.org/q_multi_geojson", + "validity": { + "mode": "season" + }, + "input_layers": [ + { + "id": "spi_3m", + "importance": 0.5, + "key": [ + "CHIRPS", + "R1S_DEKAD" + ], + "aggregation": "last_dekad" + }, + { + "id": "lst_anomaly", + "importance": 0.25, + "key": [ + "MODIS", + "MYD11C2_TDD_DEKAD" + ], + "aggregation": "last_dekad" + }, + { + "id": "ndvi_dekad", + "importance": 0.25, + "key": [ + "MODIS", + "NDVI_smoothed_5KM" + ], + "aggregation": "average", + "invert": "True" + } + ], + "legend": [ + { + "value": 0, + "label": "0 - 0.1", + "color": "#672200" + }, + { + "value": 0.1, + "label": "0.1 - 0.2", + "color": "#a93800" + }, + { + "value": 0.2, + "label": "0.2 - 0.3", + "color": "#e59800" + }, + { + "value": 0.3, + "label": "0.3 - 0.4", + "color": "#ffe769" + }, + { + "value": 0.4, + "label": "0.4 - 0.5", + "color": "#f0f0f0" + }, + { + "value": 0.5, + "label": "0.5 - 0.6", + "color": "#f0f0f0" + }, + { + "value": 0.6, + "label": "0.6 - 0.7", + "color": "#a9c6d6" + }, + { + "value": 0.7, + "label": "0.7 - 0.8", + "color": "#019ac4" + }, + { + "value": 0.8, + "label": "0.8 - 0.9", + "color": "#014c73" + }, + { + "value": 0.9, + "label": "0.9 - 1", + "color": "#014c73" + } + ], + "legend_text": "Composite Drought Index v1. SPI weight = 0.5; LST weight = 0.25; NDVI weight = 0.25", + "aggregation": "pixel", + "date_layer": "spi_3m", + "start_date": "2020-12-01", + "end_date": "2021-07-31", + "opacity": 0.7 + }, + "cdi_v2_monthly": { + "title": "Monthly Combined Drought Index (v2)", "type": "composite", + "period": "monthly", "base_url": "https://hip-service.ovio.org/q_multi_geojson", + "validity": { + "forward": 1, + "backward": 2, + "mode": "dekad" + }, "input_layers": [ { "id": "spi_1m", @@ -4201,6 +4310,103 @@ "end_date": "2021-07-31", "opacity": 0.7 }, + "cdi_v2_seasonal": { + "title": "Seasonal Combined Drought Index (v2)", + "type": "composite", + "period": "seasonal", + "base_url": "https://hip-service.ovio.org/q_multi_geojson", + "validity": { + "mode": "season" + }, + "input_layers": [ + { + "id": "spi_3m", + "importance": 0.3, + "key": [ + "CHIRPS", + "R1S_DEKAD" + ], + "aggregation": "last_dekad" + }, + { + "id": "lst_anomaly", + "importance": 0.3, + "key": [ + "MODIS", + "MYD11C2_TDD_DEKAD" + ], + "aggregation": "last_dekad" + }, + { + "id": "ndvi_dekad", + "importance": 0.4, + "key": [ + "MODIS", + "NDVI_smoothed_5KM" + ], + "aggregation": "average", + "invert": "True" + } + ], + "legend": [ + { + "value": 0, + "label": "0 - 0.1", + "color": "#672200" + }, + { + "value": 0.1, + "label": "0.1 - 0.2", + "color": "#a93800" + }, + { + "value": 0.2, + "label": "0.2 - 0.3", + "color": "#e59800" + }, + { + "value": 0.3, + "label": "0.3 - 0.4", + "color": "#ffe769" + }, + { + "value": 0.4, + "label": "0.4 - 0.5", + "color": "#f0f0f0" + }, + { + "value": 0.5, + "label": "0.5 - 0.6", + "color": "#f0f0f0" + }, + { + "value": 0.6, + "label": "0.6 - 0.7", + "color": "#a9c6d6" + }, + { + "value": 0.7, + "label": "0.7 - 0.8", + "color": "#019ac4" + }, + { + "value": 0.8, + "label": "0.8 - 0.9", + "color": "#014c73" + }, + { + "value": 0.9, + "label": "0.9 - 1", + "color": "#014c73" + } + ], + "legend_text": "Composite Drought Index v2. SPI weight = 0.3; LST weight = 0.3; NDVI weight = 0.4", + "aggregation": "pixel", + "date_layer": "spi_3m", + "start_date": "2020-12-01", + "end_date": "2021-07-31", + "opacity": 0.7 + }, "daily_rainfall_forecast": { "title": "Rolling daily rainfall forecast", "type": "wms", diff --git a/frontend/src/config/jordan/prism.json b/frontend/src/config/jordan/prism.json index 8aae6b164..bbd678bb8 100644 --- a/frontend/src/config/jordan/prism.json +++ b/frontend/src/config/jordan/prism.json @@ -211,7 +211,38 @@ "vulnerability": ["vulnerability_scaled"] }, "drought": { - "combined_drought_index": ["cdi_v1", "cdi_v2"] + "combined_drought_index": [ + { + "group_title": "Combined Drought Index v1:", + "activate_all": false, + "layers": [ + { + "id": "cdi_v1_monthly", + "label": "Monthly", + "main": true + }, + { + "id": "cdi_v1_seasonal", + "label": "Seasonal" + } + ] + }, + { + "group_title": "Combined Drought Index v2:", + "activate_all": false, + "layers": [ + { + "id": "cdi_v2_monthly", + "label": "Monthly", + "main": true + }, + { + "id": "cdi_v2_seasonal", + "label": "Seasonal" + } + ] + } + ] } } } diff --git a/frontend/src/config/mozambique/layers.json b/frontend/src/config/mozambique/layers.json index dc90ce775..ef05d1bdc 100644 --- a/frontend/src/config/mozambique/layers.json +++ b/frontend/src/config/mozambique/layers.json @@ -2770,7 +2770,13 @@ "cdi": { "title": "Combined Drought Index", "type": "composite", + "period": "monthly", "base_url": "https://hip-service.ovio.org/q_multi_geojson", + "validity": { + "forward": 1, + "backward": 2, + "mode": "dekad" + }, "input_layers": [ { "id": "spi_1m", @@ -2781,7 +2787,7 @@ { "id": "et0", "importance": 0.3, - "key": ["FAO", "WAPOR_ET0_DEKAD_LTA"], + "key": ["FAO", "WAPOR_ET0_DEKAD"], "aggregation": "last_dekad" }, { @@ -2853,7 +2859,13 @@ "cdi_test": { "title": "Combined Drought Index (v1)", "type": "composite", + "period": "monthly", "base_url": "https://hip-service.ovio.org/q_multi_geojson", + "validity": { + "forward": 1, + "backward": 2, + "mode": "dekad" + }, "input_layers": [ { "id": "spi_1m", diff --git a/frontend/src/config/mozambique/prism.json b/frontend/src/config/mozambique/prism.json index 62e9433c5..290d3b7c4 100644 --- a/frontend/src/config/mozambique/prism.json +++ b/frontend/src/config/mozambique/prism.json @@ -285,7 +285,11 @@ ] } ], - "dry_periods": ["days_dry", "streak_dry_days"], + "dry_periods": [ + "days_dry", + "streak_dry_days", + "cdi" + ], "extreme_rain_events": [ { "group_title": "Rainfall categories:", diff --git a/frontend/src/config/types.ts b/frontend/src/config/types.ts index 9eab17bcc..c6090155a 100644 --- a/frontend/src/config/types.ts +++ b/frontend/src/config/types.ts @@ -320,7 +320,7 @@ export class CommonLayerProps { }, */ @optional - group?: MenuGroup; + group?: MenuGroup = undefined; // Defaulting to undefined to make sure that the group property is writable @optional validity?: Validity; // Include additional dates in the calendar based on the number provided. @@ -408,6 +408,7 @@ interface FeatureInfoProps { export enum DatesPropagation { DAYS = 'days', DEKAD = 'dekad', + SEASON = 'season', } export type ValidityPeriod = { @@ -469,6 +470,7 @@ enum AggregationOptions { export class CompositeLayerProps extends CommonLayerProps { type: 'composite' = 'composite'; + period: 'monthly' | 'seasonal'; baseUrl: string; @makeRequired diff --git a/frontend/src/context/anticipatoryActionStateSlice/utils.ts b/frontend/src/context/anticipatoryActionStateSlice/utils.ts index 4c21ac112..65c6067ec 100644 --- a/frontend/src/context/anticipatoryActionStateSlice/utils.ts +++ b/frontend/src/context/anticipatoryActionStateSlice/utils.ts @@ -242,10 +242,6 @@ export function calculateMapRenderedDistricts({ }: CalculateMapRenderedDistrictsParams) { const { selectedDate, categories } = filters; const season = getSeason(selectedDate); - // eslint-disable-next-line no-console - console.log({ selectedDate }); - // eslint-disable-next-line no-console - console.log(season); const res = Object.entries(data) .map(([winKey, districts]) => { diff --git a/frontend/src/context/datasetStateSlice.ts b/frontend/src/context/datasetStateSlice.ts index a2e123ecd..a440b5ea5 100644 --- a/frontend/src/context/datasetStateSlice.ts +++ b/frontend/src/context/datasetStateSlice.ts @@ -136,8 +136,11 @@ export const loadEWSDataset = async ( const results: DataItem[] = dataPoints.map(item => { const [measureDate, value] = item.value; + // offset back from UTC to local time so that the date is displayed correctly + // i.e. in Cambodia Time as it is received. + const offset = new Date().getTimezoneOffset(); return { - date: getTimeInMilliseconds(measureDate), + date: getTimeInMilliseconds(measureDate) - offset * 60 * 1000, values: { measure: value.toString() }, }; }); diff --git a/frontend/src/context/layers/composite_data.ts b/frontend/src/context/layers/composite_data.ts index 280e261ba..97c44e3b4 100644 --- a/frontend/src/context/layers/composite_data.ts +++ b/frontend/src/context/layers/composite_data.ts @@ -2,9 +2,9 @@ import { FeatureCollection } from 'geojson'; import { appConfig } from 'config'; import type { CompositeLayerProps } from 'config/types'; import { fetchWithTimeout } from 'utils/fetch-with-timeout'; -import { LocalError } from 'utils/error-utils'; +import { HTTPError, LocalError } from 'utils/error-utils'; import { addNotification } from 'context/notificationStateSlice'; -import { getFormattedDate } from 'utils/date-utils'; +import { getFormattedDate, getSeasonBounds } from 'utils/date-utils'; import type { LayerDataParams, LazyLoader } from './layer-data'; @@ -14,10 +14,15 @@ export const fetchCompositeLayerData: LazyLoader = () => async (params: LayerDataParams, { dispatch }) => { const { layer, date } = params; - const startDate = date ? new Date(date) : new Date(); - // Setting an end date one month after the start date, adding support for seasons in WFP-VAM/prism-app#1301 - const endDate = new Date(startDate); - endDate.setMonth(endDate.getMonth() + 1); + const referenceDate = date ? new Date(date) : new Date(); + const seasonBounds = getSeasonBounds(referenceDate); + const useMonthly = !layer.period || layer.period === 'monthly'; + const startDate = useMonthly ? referenceDate : seasonBounds.start; + // For monthly, setting an end date to one month after the start date + // For seasonal, setting an end date to the end of the season + const endDate = useMonthly + ? new Date(startDate).setMonth(startDate.getMonth() + 1) + : seasonBounds.end; const { baseUrl, @@ -62,7 +67,7 @@ export const fetchCompositeLayerData: LazyLoader = return geojson; } catch (error) { - if (!(error instanceof LocalError)) { + if (!(error instanceof LocalError) && !(error instanceof HTTPError)) { return undefined; } console.error(error); diff --git a/frontend/src/utils/date-utils.ts b/frontend/src/utils/date-utils.ts index dd91b3256..ab92a3ec3 100644 --- a/frontend/src/utils/date-utils.ts +++ b/frontend/src/utils/date-utils.ts @@ -6,13 +6,23 @@ export interface StartEndDate { endDate?: number; } +const millisecondsInADay = 24 * 60 * 60 * 1000; + +export const dateWithoutTime = (date: number | Date): number => { + const cleanDate = date instanceof Date ? date.getTime() : date; + return cleanDate - (cleanDate % millisecondsInADay); +}; + export const datesAreEqualWithoutTime = ( date1: number | Date, date2: number | Date, ): boolean => { - const d1 = new Date(date1).setUTCHours(0, 0, 0, 0); - const d2 = new Date(date2).setUTCHours(0, 0, 0, 0); - return d1 === d2; + const cleanDate1 = date1 instanceof Date ? date1.getTime() : date1; + const cleanDate2 = date2 instanceof Date ? date2.getTime() : date2; + return ( + cleanDate1 - (cleanDate1 % millisecondsInADay) === + cleanDate2 - (cleanDate2 % millisecondsInADay) + ); }; function diffInDays(date1: Date, date2: Date) { @@ -172,3 +182,21 @@ export const getFormattedDate = ( export const getTimeInMilliseconds = (date: string | number) => new Date(date).getTime(); + +const SEASON_MAP: [number, number][] = [ + [0, 2], + [3, 5], + [6, 8], + [9, 11], +]; + +export const getSeasonBounds = (date: Date) => { + const monthIndex = date.getMonth(); + const foundSeason = SEASON_MAP.find( + season => season[0] <= monthIndex && monthIndex <= season[1], + ) as [number, number]; + return { + start: new Date(date.getFullYear(), foundSeason[0], 1), + end: new Date(date.getFullYear(), foundSeason[1] + 1, 1), + }; +}; diff --git a/frontend/src/utils/layers-utils.tsx b/frontend/src/utils/layers-utils.tsx index c86d6cf63..1ad37ed08 100644 --- a/frontend/src/utils/layers-utils.tsx +++ b/frontend/src/utils/layers-utils.tsx @@ -23,7 +23,7 @@ import { } from 'context/mapStateSlice/selectors'; import { addNotification } from 'context/notificationStateSlice'; import { availableDatesSelector } from 'context/serverStateSlice'; -import { countBy, get, pickBy } from 'lodash'; +import { countBy, get, pickBy, uniqBy } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { LocalError } from 'utils/error-utils'; @@ -40,7 +40,7 @@ import { datesAreEqualWithoutTime, binaryIncludes, getFormattedDate, - getTimeInMilliseconds, + dateWithoutTime, } from './date-utils'; const dateSupportLayerTypes: Array = [ @@ -130,7 +130,10 @@ const useLayers = () => { } if (layer.type === 'composite') { // some WMS layer might not have date dimension (i.e. static data) - return layer.dateLayer in serverAvailableDates; + return ( + layer.id in serverAvailableDates || + layer.dateLayer in serverAvailableDates + ); } return dateSupportLayerTypes.includes(layer.type); }) @@ -157,7 +160,14 @@ const useLayers = () => { // Combine dates for all AA windows to allow selecting AA for the whole period return AAAvailableDatesCombined; } - return getPossibleDatesForLayer(layer, serverAvailableDates); + const possibleDates = getPossibleDatesForLayer( + layer, + serverAvailableDates, + ); + const uniqueDates = uniqBy(possibleDates, dateItem => + dateWithoutTime(dateItem.displayDate), + ); + return uniqueDates; }) .filter(value => value) // null check .flat() @@ -175,6 +185,10 @@ const useLayers = () => { if (selectedLayersWithDateSupport.length === 0) { return []; } + const selectedNonAALayersWithDateSupport = + selectedLayersWithDateSupport.filter( + layer => layer.type !== 'anticipatory_action', + ); /* Only keep the dates which were duplicated the same amount of times as the amount of layers active...and convert back to array. */ @@ -182,13 +196,13 @@ const useLayers = () => { return Object.keys( pickBy( selectedLayerDatesDupCount, - dupTimes => dupTimes >= selectedLayersWithDateSupport.length, + dupTimes => dupTimes >= selectedNonAALayersWithDateSupport.length, ), // convert back to number array after using YYYY-MM-DD strings in countBy ) .map(dateString => new Date(dateString).setUTCHours(12, 0, 0, 0)) .sort((a, b) => a - b); - }, [selectedLayerDatesDupCount, selectedLayersWithDateSupport.length]); + }, [selectedLayerDatesDupCount, selectedLayersWithDateSupport]); const defaultLayer = useMemo(() => get(appConfig, 'defaultLayer'), []); @@ -389,24 +403,24 @@ const useLayers = () => { // let users know if the layers selected are not possible to view together. useEffect(() => { - // TODO: Why is this the case here? maybe we should remove it; + const nonBoundaryLayers = selectedLayers.filter( + layer => layer.type !== 'boundary', + ); if ( - // eslint-disable-next-line no-constant-condition selectedLayerDates.length !== 0 || selectedLayersWithDateSupport.length === 0 || - !selectedDate || - 1 + !selectedDate ) { return; } // WARNING - This logic doesn't apply anymore if we order layers differently... - const layerToRemove = selectedLayers[selectedLayers.length - 2]; - const layerToKeep = selectedLayers[selectedLayers.length - 1]; + const layerToRemove = nonBoundaryLayers[nonBoundaryLayers.length - 2]; + const layerToKeep = nonBoundaryLayers[nonBoundaryLayers.length - 1]; dispatch( addNotification({ - message: `No dates overlap with the selected layers. Removing previous layer: ${layerToRemove.id}.`, + message: `No dates overlap with the selected layers. Removing layer: ${layerToRemove.title || layerToRemove.id}.`, type: 'warning', }), ); @@ -432,74 +446,78 @@ const useLayers = () => { [AAAvailableDatesCombined, serverAvailableDates], ); - useEffect(() => { - if ( - !selectedDate || - !urlDate || - getTimeInMilliseconds(urlDate) === selectedDate - ) { - return; - } - selectedLayersWithDateSupport.forEach(layer => { - const jsSelectedDate = new Date(selectedDate); - - const AADatesLoaded = - layer.type !== 'anticipatory_action' || - layer.id in serverAvailableDates; - - if ( - serverAvailableDatesAreEmpty || - possibleDatesForLayerIncludeSelectedDate(layer, jsSelectedDate) || - !AADatesLoaded - ) { - return; + const checkSelectedDateForLayerSupport = useCallback( + (providedSelectedDate?: number): number | null => { + if (!providedSelectedDate || selectedLayerDates.length === 0) { + return null; } - - const closestDate = findClosestDate(selectedDate, selectedLayerDates); - - if ( - datesAreEqualWithoutTime( - jsSelectedDate.valueOf(), - closestDate.valueOf(), - ) - ) { - console.warn({ closestDate }); - console.warn( - 'closest dates is the same as selected date, not updating url', + let closestDate: number | null = null; + selectedLayersWithDateSupport.forEach(layer => { + const jsSelectedDate = new Date(providedSelectedDate); + + const AADatesLoaded = + layer.type !== 'anticipatory_action' || + layer.id in serverAvailableDates; + + if ( + serverAvailableDatesAreEmpty || + possibleDatesForLayerIncludeSelectedDate(layer, jsSelectedDate) || + !AADatesLoaded + ) { + return; + } + + // eslint-disable-next-line fp/no-mutation + closestDate = findClosestDate(providedSelectedDate, selectedLayerDates); + + if ( + datesAreEqualWithoutTime( + jsSelectedDate.valueOf(), + closestDate.valueOf(), + ) + ) { + console.warn({ closestDate }); + console.warn( + 'closest dates is the same as selected date, not updating url', + ); + } else { + updateHistory( + 'date', + getFormattedDate(closestDate, DateFormat.Default) as string, + ); + } + + dispatch( + addNotification({ + message: `No data was found for layer '${ + layer.title + }' on ${getFormattedDate( + jsSelectedDate, + DateFormat.Default, + )}. The closest date ${getFormattedDate( + closestDate, + DateFormat.Default, + )} has been loaded instead.`, + type: 'warning', + }), ); - } else { - updateHistory( - 'date', - getFormattedDate(closestDate, DateFormat.Default) as string, - ); - } + }); + return closestDate; + }, + [ + dispatch, + possibleDatesForLayerIncludeSelectedDate, + selectedLayerDates, + selectedLayersWithDateSupport, + serverAvailableDates, + serverAvailableDatesAreEmpty, + updateHistory, + ], + ); - dispatch( - addNotification({ - message: `No data was found for layer '${ - layer.title - }' on ${getFormattedDate( - jsSelectedDate, - DateFormat.Default, - )}. The closest date ${getFormattedDate( - closestDate, - DateFormat.Default, - )} has been loaded instead.`, - type: 'warning', - }), - ); - }); - }, [ - dispatch, - possibleDatesForLayerIncludeSelectedDate, - selectedDate, - selectedLayerDates, - selectedLayersWithDateSupport, - serverAvailableDates, - serverAvailableDatesAreEmpty, - updateHistory, - urlDate, - ]); + useEffect(() => { + checkSelectedDateForLayerSupport(selectedDate); + }, [checkSelectedDateForLayerSupport, selectedDate]); return { adminBoundariesExtent, @@ -508,6 +526,7 @@ const useLayers = () => { selectedLayerDates, selectedLayers, selectedLayersWithDateSupport, + checkSelectedDateForLayerSupport, }; }; diff --git a/frontend/src/utils/server-utils.test.ts b/frontend/src/utils/server-utils.test.ts index 67a70024c..965ed4a4a 100644 --- a/frontend/src/utils/server-utils.test.ts +++ b/frontend/src/utils/server-utils.test.ts @@ -91,6 +91,12 @@ describe('Test generateIntermediateDateItemFromValidity', () => { startDate: layer.dates[0], endDate: new Date('2023-12-11').setHours(12, 0), }, + { + displayDate: new Date('2023-12-11').setHours(12, 0), + queryDate: layer.dates[0], + startDate: layer.dates[0], + endDate: new Date('2023-12-11').setHours(12, 0), + }, { displayDate: new Date('2023-12-11').setHours(12, 0), queryDate: layer.dates[1], @@ -151,6 +157,12 @@ describe('Test generateIntermediateDateItemFromValidity', () => { startDate: layer.dates[1], endDate: new Date('2023-12-21').setHours(12, 0), }, + { + displayDate: new Date('2023-12-21').setHours(12, 0), + queryDate: layer.dates[1], + startDate: layer.dates[1], + endDate: new Date('2023-12-21').setHours(12, 0), + }, { displayDate: new Date('2023-12-21').setHours(12, 0), queryDate: layer.dates[2], @@ -297,6 +309,12 @@ describe('Test generateIntermediateDateItemFromValidity', () => { startDate: new Date('2023-11-21').setHours(12, 0), endDate: layer.dates[0], }, + { + displayDate: new Date('2023-12-01').setHours(12, 0), + queryDate: layer.dates[0], + startDate: new Date('2023-11-21').setHours(12, 0), + endDate: layer.dates[0], + }, { displayDate: new Date('2023-12-01').setHours(12, 0), queryDate: layer.dates[1], @@ -443,6 +461,12 @@ describe('Test generateIntermediateDateItemFromValidity', () => { endDate: layer.dates[0], startDate: new Date('2023-11-21').setHours(12, 0), }, + { + displayDate: new Date('2023-12-01').setHours(12, 0), + queryDate: layer.dates[0], + endDate: layer.dates[0], + startDate: new Date('2023-11-21').setHours(12, 0), + }, { displayDate: new Date('2023-12-01').setHours(12, 0), queryDate: layer.dates[1], diff --git a/frontend/src/utils/server-utils.ts b/frontend/src/utils/server-utils.ts index 08e38e8aa..53a42d2bc 100644 --- a/frontend/src/utils/server-utils.ts +++ b/frontend/src/utils/server-utils.ts @@ -1,5 +1,5 @@ import { oneDayInMs } from 'components/MapView/LeftPanel/utils'; -import { get, merge, snakeCase, sortBy, sortedUniqBy } from 'lodash'; +import { get, merge, snakeCase, sortBy } from 'lodash'; import { WFS, WMS, fetchCoverageLayerDays, formatUrl } from 'prism-common'; import { Dispatch } from 'redux'; import { appConfig, safeCountry } from '../config'; @@ -31,11 +31,11 @@ import { addNotification } from '../context/notificationStateSlice'; import { fetchACLEDDates } from './acled-utils'; import { StartEndDate, - binaryIncludes, datesAreEqualWithoutTime, generateDateItemsRange, generateDatesRange, getFormattedDate, + getSeasonBounds, } from './date-utils'; import { LocalError } from './error-utils'; import { createEWSDatesArray } from './ews-utils'; @@ -43,27 +43,51 @@ import { fetchWithTimeout } from './fetch-with-timeout'; import { queryParamsToString } from './url-utils'; /** - * Function that gets the correct date used to make the request. If available dates is undefined. Return selectedDate as default. + * Function that gets the correct date item. * - * @return unix timestamp + * @return DateItem */ -export const getRequestDate = ( +export const getRequestDateItem = ( layerAvailableDates: DateItem[] | undefined, selectedDate?: number, -): number | undefined => { + defaultToMostRecent: boolean = true, +): DateItem | undefined => { if (!selectedDate) { return undefined; } if (!layerAvailableDates || layerAvailableDates.length === 0) { - return selectedDate; + return undefined; } const dateItem = layerAvailableDates.find(date => datesAreEqualWithoutTime(date.displayDate, selectedDate), ); + if (!dateItem && defaultToMostRecent) { + return layerAvailableDates[layerAvailableDates.length - 1]; + } + + return dateItem; +}; + +/** + * Function that gets the correct date used to make the request. If available dates is undefined. Return selectedDate as default. + * + * @return unix timestamp + */ +export const getRequestDate = ( + layerAvailableDates: DateItem[] | undefined, + selectedDate?: number, + defaultToMostRecent = true, +): number | undefined => { + const dateItem = getRequestDateItem( + layerAvailableDates, + selectedDate, + defaultToMostRecent, + ); + if (!dateItem) { - return layerAvailableDates[layerAvailableDates.length - 1].queryDate; + return selectedDate; } return dateItem.queryDate; @@ -116,7 +140,11 @@ export const getPossibleDatesForLayer = ( case 'composite': { // Filter dates that are after layer.startDate const startDateTimestamp = Date.parse(layer.startDate); - return (serverAvailableDates[layer.dateLayer] || []).filter( + const layerServerAvailableDates = + serverAvailableDates[layer.id] || + serverAvailableDates[layer.dateLayer] || + []; + return layerServerAvailableDates.filter( date => date.displayDate > startDateTimestamp, ); } @@ -376,6 +404,12 @@ export function generateIntermediateDateItemFromValidity( startDate.setDate(DekadStartingDays[newDekadStartIndex]); startDate.setMonth(startDate.getMonth() + nMonthsBackward); } + } else if (mode === DatesPropagation.SEASON) { + // TODO: add support flexible seasons (i.e. s1_start, s1_end, etc.) + const { start, end } = getSeasonBounds(startDate); + + startDate.setTime(start.getTime()); + endDate.setTime(end.getTime() - oneDayInMs); } else { return []; } @@ -386,25 +420,20 @@ export function generateIntermediateDateItemFromValidity( // convert the available days for a specific day to the DefaultDate format const dateItemsToAdd = daysToAdd.map(dateToAdd => ({ displayDate: dateToAdd, - queryDate: date.getTime(), + queryDate: + mode === DatesPropagation.SEASON + ? startDate.getTime() + : date.getTime(), startDate: startDate.getTime(), endDate: endDate.getTime(), })); - // We filter the dates that don't include the displayDate of the previous item array - const filteredDateItems = acc.filter( - dateItem => !binaryIncludes(daysToAdd, dateItem.displayDate, x => x), - ); - - return [...filteredDateItems, ...dateItemsToAdd]; + return [...acc, ...dateItemsToAdd]; }, []); // We sort the defaultDateItems and the dateItemsWithValidity and we order by displayDate to filter the duplicates // or the overlapping dates - return sortedUniqBy( - sortBy(dateItemsWithValidity, 'displayDate'), - 'displayDate', - ); + return sortBy(dateItemsWithValidity, 'displayDate'); } /** @@ -469,7 +498,11 @@ const localWMSGetLayerDates = async ( const layerDefinitionsBluePrint: AvailableDates = Object.keys( LayerDefinitions, ).reduce((acc, layerDefinitionKey) => { - const { serverLayerName } = LayerDefinitions[layerDefinitionKey] as any; + const layer = LayerDefinitions[layerDefinitionKey]; + const serverLayerName = + layer.type === 'composite' + ? (LayerDefinitions[layer.dateLayer] as WMSLayerProps).serverLayerName + : (layer as WMSLayerProps).serverLayerName; if (!serverLayerName) { return { ...acc, @@ -512,23 +545,42 @@ export async function getLayersAvailableDates( const wmsServerUrls: string[] = get(appConfig, 'serversUrls.wms', []); const wcsServerUrls: string[] = get(appConfig, 'serversUrls.wcs', []); + const compositeLayers = Object.values(LayerDefinitions).filter( + (layer): layer is CompositeLayerProps => layer.type === 'composite', + ); + + const compositeLayersWithDateLayerTypeMap: { + [key: string]: string; + } = compositeLayers.reduce( + (acc, layer) => ({ + ...acc, + [layer.id]: LayerDefinitions[layer.dateLayer].type, + }), + {}, + ); + const pointDataLayers = Object.values(LayerDefinitions).filter( (layer): layer is PointDataLayerProps => - layer.type === 'point_data' && Boolean(layer.dateUrl), + (layer.type === 'point_data' && Boolean(layer.dateUrl)) || + compositeLayersWithDateLayerTypeMap[layer.id] === 'point_data', ); const adminWithDateLayers = Object.values(LayerDefinitions).filter( (layer): layer is AdminLevelDataLayerProps => - layer.type === 'admin_level_data' && Boolean(layer.dates), + (layer.type === 'admin_level_data' && Boolean(layer.dates)) || + compositeLayersWithDateLayerTypeMap[layer.id] === 'admin_level_data', ); const staticRasterWithDateLayers = Object.values(LayerDefinitions).filter( (layer): layer is StaticRasterLayerProps => - layer.type === 'static_raster' && Boolean(layer.dates), + (layer.type === 'static_raster' && Boolean(layer.dates)) || + compositeLayersWithDateLayerTypeMap[layer.id] === 'static_raster', ); const WCSWMSLayers = Object.values(LayerDefinitions).filter( - (layer): layer is WMSLayerProps => layer.type === 'wms', + (layer): layer is WMSLayerProps => + layer.type === 'wms' || + compositeLayersWithDateLayerTypeMap[layer.id] === 'wms', ); /** @@ -540,10 +592,14 @@ export async function getLayersAvailableDates( */ const mapServerDatesToLayerIds = ( serverDates: Record, - layers: WMSLayerProps[], + layers: (WMSLayerProps | CompositeLayerProps)[], ): Record => layers.reduce((acc: Record, layer) => { - const layerDates = serverDates[layer.serverLayerName]; + const serverLayerName = + layer.type === 'composite' + ? (LayerDefinitions[layer.dateLayer] as WMSLayerProps).serverLayerName + : layer.serverLayerName; + const layerDates = serverDates[serverLayerName]; if (layerDates) { // Filter WMS layers by startDate, used for forecast layers in particular. if (layer.startDate) {