diff --git a/src/client/app/components/CompareLineChartComponent.tsx b/src/client/app/components/CompareLineChartComponent.tsx new file mode 100644 index 000000000..5daa924e4 --- /dev/null +++ b/src/client/app/components/CompareLineChartComponent.tsx @@ -0,0 +1,237 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as moment from 'moment'; +import * as React from 'react'; +import Plot from 'react-plotly.js'; +import { readingsApi, stableEmptyLineReadings } from '../redux/api/readingsApi'; +import { useAppSelector } from '../redux/reduxHooks'; +import { selectCompareLineQueryArgs } from '../redux/selectors/chartQuerySelectors'; +import { selectLineUnitLabel } from '../redux/selectors/plotlyDataSelectors'; +import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; +import Locales from '../types/locales'; +import translate from '../utils/translate'; +import SpinnerComponent from './SpinnerComponent'; +import { selectGraphState, selectShiftAmount } from '../redux/slices/graphSlice'; +import ThreeDPillComponent from './ThreeDPillComponent'; +import { selectThreeDComponentInfo } from '../redux/selectors/threeDSelectors'; +import { selectPlotlyGroupData, selectPlotlyMeterData } from '../redux/selectors/lineChartSelectors'; +import { MeterOrGroup, ShiftAmount } from '../types/redux/graph'; +import { showInfoNotification, showWarnNotification } from '../utils/notifications'; +import { setHelpLayout } from './ThreeDComponent'; +import { toast } from 'react-toastify'; + +/** + * @returns plotlyLine graphic + */ +export default function CompareLineChartComponent() { + const graphState = useAppSelector(selectGraphState); + const meterOrGroupID = useAppSelector(selectThreeDComponentInfo).meterOrGroupID; + const unitLabel = useAppSelector(selectLineUnitLabel); + const locale = useAppSelector(selectSelectedLanguage); + const shiftAmount = useAppSelector(selectShiftAmount); + const { args, shouldSkipQuery, argsDeps } = useAppSelector(selectCompareLineQueryArgs); + // getting the time interval of current data + const timeInterval = graphState.queryTimeInterval; + const shiftInterval = graphState.shiftTimeInterval; + // Layout for the plot + let layout = {}; + + // Fetch original data, and derive plotly points + const { data, isFetching } = graphState.threeD.meterOrGroup === MeterOrGroup.meters ? + readingsApi.useLineQuery(args, + { + skip: shouldSkipQuery, + selectFromResult: ({ data, ...rest }) => ({ + ...rest, + data: selectPlotlyMeterData(data ?? stableEmptyLineReadings, + { ...argsDeps, compatibleEntities: [meterOrGroupID!] }) + }) + }) + : + readingsApi.useLineQuery(args, + { + skip: shouldSkipQuery, + selectFromResult: ({ data, ...rest }) => ({ + ...rest, + data: selectPlotlyGroupData(data ?? stableEmptyLineReadings, + { ...argsDeps, compatibleEntities: [meterOrGroupID!] }) + }) + }); + + // Getting the shifted data + const { data: dataNew, isFetching: isFetchingNew } = graphState.threeD.meterOrGroup === MeterOrGroup.meters ? + readingsApi.useLineQuery({ ...args, timeInterval: shiftInterval.toString() }, + { + skip: shouldSkipQuery, + selectFromResult: ({ data, ...rest }) => ({ + ...rest, + data: selectPlotlyMeterData(data ?? stableEmptyLineReadings, + { ...argsDeps, compatibleEntities: [meterOrGroupID!] }) + }) + }) + : + readingsApi.useLineQuery({ ...args, timeInterval: shiftInterval.toString() }, + { + skip: shouldSkipQuery, + selectFromResult: ({ data, ...rest }) => ({ + ...rest, + data: selectPlotlyGroupData(data ?? stableEmptyLineReadings, + { ...argsDeps, compatibleEntities: [meterOrGroupID!] }) + }) + }); + + // Check if there is at least one valid graph for current data and shifted data + const enoughData = data.find(data => data.x!.length > 1) && dataNew.find(dataNew => dataNew.x!.length > 1); + + // Customize the layout of the plot + // See https://community.plotly.com/t/replacing-an-empty-graph-with-a-message/31497 for showing text `not plot. + if (!meterOrGroupID) { + layout = setHelpLayout(translate('select.meter.group')); + } else if (!timeInterval.getIsBounded() || !shiftInterval.getIsBounded()) { + layout = setHelpLayout(translate('please.set.the.date.range')); + } else if (shiftAmount === ShiftAmount.none) { + layout = setHelpLayout(translate('select.shift.amount')); + } else if (!enoughData) { + layout = setHelpLayout(translate('no.data.in.range')); + } else { + if (!isFetching && !isFetchingNew) { + // Checks/warnings on received reading data + checkReceivedData(data[0].x, dataNew[0].x); + } + layout = { + autosize: true, showlegend: true, + legend: { x: 0, y: 1.1, orientation: 'h' }, + // 'fixedrange' on the yAxis means that dragging is only allowed on the xAxis which we utilize for selecting dateRanges + yaxis: { title: unitLabel, gridcolor: '#ddd', fixedrange: true }, + xaxis: { + // Set range for x-axis based on timeIntervalStr so that current data and shifted data is aligned + range: timeInterval.getIsBounded() + ? [timeInterval.getStartTimestamp(), timeInterval.getEndTimestamp()] + : undefined + }, + xaxis2: { + titlefont: { color: '#1AA5F0' }, + tickfont: { color: '#1AA5F0' }, + overlaying: 'x', + side: 'top', + // Set range for x-axis2 based on shiftIntervalStr so that current data and shifted data is aligned + range: shiftInterval.getIsBounded() + ? [shiftInterval.getStartTimestamp(), shiftInterval.getEndTimestamp()] + : undefined + } + }; + } + + // Adding information to the shifted data so that it can be plotted on the same graph with current data + const updateDataNew = dataNew.map(item => ({ + ...item, + name: 'Shifted ' + item.name, + line: { ...item.line, color: '#1AA5F0' }, + xaxis: 'x2', + text: Array.isArray(item.text) + ? item.text.map(text => text.replace('
', '
Shifted ')) + : item.text?.replace('
', '
Shifted ') + })); + + return ( + <> + + {isFetching || isFetchingNew + ? + : + } + + + + ); + +} + +/** + * If the number of points differs for the original and shifted lines, the data will not appear at the same places horizontally. + * The time interval in the original and shifted line for the actual readings can have issues. + * While the requested time ranges should be the same, the actually returned readings may differ. + * This can happen if there are readings missing including start, end or between. If the number of readings vary then there is an issue. + * If not, it is unlikely but can happen if there are missing readings in both lines that do not align but there are the same number missing in both. + * This is an ugly edge case that OED is not going to try to catch now. + * Use the last index in Redux state as a proxy for the number since need that below. + * @param originalReading original data to compare + * @param shiftedReading shifted data to compare + */ +function checkReceivedData(originalReading: any, shiftedReading: any) { + let numberPointsSame = true; + if (originalReading.length !== shiftedReading.length) { + // If the number of points vary then then scales will not line up point by point. Warn the user. + numberPointsSame = false; + showWarnNotification( + `The original line has ${originalReading.length} readings but the shifted line has ${shiftedReading.length}` + + ' readings which means the points will not align horizontally.' + ); + } + // Now see if the original and shifted lines overlap. + if (moment(shiftedReading.at(-1).toString()) > moment(originalReading.at(0).toString())) { + showInfoNotification( + `The shifted line overlaps the original line starting at ${originalReading[0]}`, + toast.POSITION.TOP_RIGHT, + 15000 + ); + } + + // Now see if day of the week aligns. + // If the number of points is not the same then no horizontal alignment so do not tell user. + const firstOriginReadingDay = moment(originalReading.at(0)?.toString()); + const firstShiftedReadingDay = moment(shiftedReading.at(0)?.toString()); + if (numberPointsSame && firstOriginReadingDay.day() === firstShiftedReadingDay.day()) { + showInfoNotification('Days of week align (unless missing readings)', + toast.POSITION.TOP_RIGHT, + 15000 + ); + } + // Now see if the month and day align. If the number of points is not the same then no horizontal + // alignment so do not tell user. Check if the first reading matches because only notify if this is true. + if (numberPointsSame && monthDateSame(firstOriginReadingDay, firstShiftedReadingDay)) { + // Loop over all readings but the first. Really okay to do first but just checked that one. + // Note length of original and shifted same so just use original. + let message = 'The month and day of the month align for the original and shifted readings'; + for (let i = 1; i < originalReading.length; i++) { + if (!monthDateSame(moment(originalReading.at(i)?.toString()), moment(shiftedReading.at(i)?.toString()))) { + // Mismatch so inform user. Should be due to leap year crossing and differing leap year. + // Only tell first mistmatch + message += ` until original reading at date ${moment(originalReading.at(i)?.toString()).format('ll')}`; + break; + } + } + showInfoNotification(message, toast.POSITION.TOP_RIGHT, 15000); + } +} + +/** + * Check if the two dates have the same date and month + * @param firstDate first date to compare + * @param secondDate second date to compare + * @returns true if the month and date are the same + */ +function monthDateSame(firstDate: moment.Moment, secondDate: moment.Moment) { + // The month (0 up numbering) and date (day of month with 1 up numbering) must match. + // The time could be checked but the granulatity should be the same for original and + // shifted readings and only mismatch if there is missing readings. In the unlikely + // event of having the same number of points but different missing readings then + // the first one will mismatch the month or day unless those happen to match in which + // case it is still true that they are generally okay so ignore all this. + return firstDate.month() === secondDate.month() && firstDate.date() === secondDate.date(); +} \ No newline at end of file diff --git a/src/client/app/components/CompareLineControlsComponent.tsx b/src/client/app/components/CompareLineControlsComponent.tsx new file mode 100644 index 000000000..607654e55 --- /dev/null +++ b/src/client/app/components/CompareLineControlsComponent.tsx @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { Input } from 'reactstrap'; +import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; +import { + selectQueryTimeInterval, selectShiftAmount, selectShiftTimeInterval, updateShiftAmount, updateShiftTimeInterval +} from '../redux/slices/graphSlice'; +import translate from '../utils/translate'; +import { FormattedMessage } from 'react-intl'; +import { ShiftAmount } from '../types/redux/graph'; +import DateRangePicker from '@wojtekmaj/react-daterange-picker'; +import { dateRangeToTimeInterval, timeIntervalToDateRange } from '../utils/dateRangeCompatibility'; +import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; +import { Value } from '@wojtekmaj/react-daterange-picker/dist/cjs/shared/types'; +import * as moment from 'moment'; +import { TimeInterval } from '../../../common/TimeInterval'; +import TooltipMarkerComponent from './TooltipMarkerComponent'; + +/** + * @returns compare line control component for compare line graph page + */ +export default function CompareLineControlsComponent() { + const dispatch = useAppDispatch(); + const shiftAmount = useAppSelector(selectShiftAmount); + const timeInterval = useAppSelector(selectQueryTimeInterval); + const locale = useAppSelector(selectSelectedLanguage); + const shiftInterval = useAppSelector(selectShiftTimeInterval); + // Hold value to store the custom date range for the shift interval + const [customDateRange, setCustomDateRange] = React.useState(shiftInterval); + + // Translation for shift amount + const shiftAmountTranslations: Record = { + none: 'select.shift.amount', + day: '1.day', + week: '1.week', + month: '1.month', + year: '1.year', + custom: 'custom.date.range' + }; + + // Update the shift interval when the shift option changes + React.useEffect(() => { + if (shiftAmount !== ShiftAmount.custom && timeInterval.getIsBounded()) { + const { shiftedStart, shiftedEnd } = shiftDate(timeInterval.getStartTimestamp(), timeInterval.getEndTimestamp(), shiftAmount); + const newInterval = new TimeInterval(shiftedStart, shiftedEnd); + dispatch(updateShiftTimeInterval(newInterval)); + // set the custom date range to the new interval + setCustomDateRange(newInterval); + } + }, [shiftAmount, timeInterval]); + + // Handle changes in shift option (week, month, year, or custom) + const handleShiftOptionChange = (value: string) => { + if (value === 'custom') { + dispatch(updateShiftAmount(ShiftAmount.custom)); + } else { + const newShiftOption = value as ShiftAmount; + dispatch(updateShiftAmount(newShiftOption)); + } + }; + + // Update date when the data range picker is used in custome shifting option + const handleCustomShiftDateChange = (value: Value) => { + setCustomDateRange(dateRangeToTimeInterval(value)); + dispatch(updateShiftTimeInterval(dateRangeToTimeInterval(value))); + }; + + return ( + <> +
+

+ + +

+ handleShiftOptionChange(e.target.value)} + > + {Object.entries(ShiftAmount).map( + ([key, value]) => ( + + ) + )} + + {/* Show date picker when custom date range is selected */} + {shiftAmount === ShiftAmount.custom && + } +
+ + ); + +} + +/** + * Shifting date function to find the shifted start date and shifted end date for shift amount that is not custom + * @param originalStart start date of current graph data + * @param originalEnd end date of current graph data + * @param shiftType shifting amount in week, month, or year + * @returns shifted start and shifted end dates for the new data + */ +export function shiftDate(originalStart: moment.Moment, originalEnd: moment.Moment, shiftType: ShiftAmount) { + let shiftedStart = originalStart.clone(); + + if (shiftType === ShiftAmount.day) { + shiftedStart = originalStart.clone().subtract(1, 'days'); + } else if (shiftType === ShiftAmount.week) { + shiftedStart = originalStart.clone().subtract(7, 'days'); + } else if (shiftType === ShiftAmount.month) { + shiftedStart = originalStart.clone().subtract(1, 'months'); + } else if (shiftType === ShiftAmount.year) { + shiftedStart = originalStart.clone().subtract(1, 'years'); + } + + // Add the number of days in the original line to the shifted start to get the end. + // This means the original and shifted lines have the same number of days. + // Let moment decide the day since it may help with leap years, etc. + const originalDateRange = originalEnd.diff(originalStart, 'days'); + const shiftedEnd = shiftedStart.clone().add(originalDateRange, 'days'); + + return { shiftedStart, shiftedEnd }; +} \ No newline at end of file diff --git a/src/client/app/components/DashboardComponent.tsx b/src/client/app/components/DashboardComponent.tsx index 5e16f9fc8..1069ae156 100644 --- a/src/client/app/components/DashboardComponent.tsx +++ b/src/client/app/components/DashboardComponent.tsx @@ -15,6 +15,7 @@ import RadarChartComponent from './RadarChartComponent'; import ThreeDComponent from './ThreeDComponent'; import UIOptionsComponent from './UIOptionsComponent'; import PlotNavComponent from './PlotNavComponent'; +import CompareLineChartComponent from './CompareLineChartComponent'; /** * React component that controls the dashboard @@ -39,6 +40,7 @@ export default function DashboardComponent() { {chartToRender === ChartTypes.map && } {chartToRender === ChartTypes.threeD && } {chartToRender === ChartTypes.radar && } + {chartToRender === ChartTypes.compareLine && } diff --git a/src/client/app/components/GraphicRateMenuComponent.tsx b/src/client/app/components/GraphicRateMenuComponent.tsx index febe41352..1cf01065f 100644 --- a/src/client/app/components/GraphicRateMenuComponent.tsx +++ b/src/client/app/components/GraphicRateMenuComponent.tsx @@ -41,7 +41,8 @@ export default function GraphicRateMenuComponent() { const displayOnChartType: ChartTypes[] = [ ChartTypes.line, ChartTypes.threeD, - ChartTypes.radar + ChartTypes.radar, + ChartTypes.compareLine ]; if (!displayOnChartType.includes(graphState.chartToRender)) { diff --git a/src/client/app/components/MoreOptionsComponent.tsx b/src/client/app/components/MoreOptionsComponent.tsx index bdc860ab3..b4a3aff05 100644 --- a/src/client/app/components/MoreOptionsComponent.tsx +++ b/src/client/app/components/MoreOptionsComponent.tsx @@ -33,7 +33,7 @@ export default function MoreOptionsComponent() { return ( <> { -
+
@@ -75,6 +75,12 @@ export default function MoreOptionsComponent() { {chartToRender == ChartTypes.radar && } {chartToRender == ChartTypes.radar && } {chartToRender == ChartTypes.radar && } + + {/*More UI options for compare line */} + {chartToRender === ChartTypes.compareLine && } + {chartToRender === ChartTypes.compareLine && } + {chartToRender === ChartTypes.compareLine && } + {chartToRender === ChartTypes.compareLine && } diff --git a/src/client/app/components/ThreeDComponent.tsx b/src/client/app/components/ThreeDComponent.tsx index 2310e8671..de96bb39d 100644 --- a/src/client/app/components/ThreeDComponent.tsx +++ b/src/client/app/components/ThreeDComponent.tsx @@ -212,7 +212,7 @@ function formatThreeDData( * @param fontSize current application state * @returns plotly layout object. */ -function setHelpLayout(helpText: string = 'Help Text Goes Here', fontSize: number = 28) { +export function setHelpLayout(helpText: string = 'Help Text Goes Here', fontSize: number = 28) { return { 'xaxis': { 'visible': false diff --git a/src/client/app/components/UIOptionsComponent.tsx b/src/client/app/components/UIOptionsComponent.tsx index 40d2f18b8..5ffdfdec4 100644 --- a/src/client/app/components/UIOptionsComponent.tsx +++ b/src/client/app/components/UIOptionsComponent.tsx @@ -15,6 +15,7 @@ import DateRangeComponent from './DateRangeComponent'; import MapControlsComponent from './MapControlsComponent'; import ReadingsPerDaySelectComponent from './ReadingsPerDaySelectComponent'; import MoreOptionsComponent from './MoreOptionsComponent'; +import CompareLineControlsComponent from './CompareLineControlsComponent'; /** * @returns the UI Control panel @@ -80,6 +81,10 @@ export default function UIOptionsComponent() { {/* UI options for radar graphic */} {chartToRender == ChartTypes.radar} + { /* Controls specific to the compare line chart */} + {chartToRender === ChartTypes.compareLine && } + {chartToRender === ChartTypes.compareLine && } +
diff --git a/src/client/app/redux/selectors/chartQuerySelectors.ts b/src/client/app/redux/selectors/chartQuerySelectors.ts index 73e68216b..b9e53bcff 100644 --- a/src/client/app/redux/selectors/chartQuerySelectors.ts +++ b/src/client/app/redux/selectors/chartQuerySelectors.ts @@ -14,6 +14,7 @@ import { selectSelectedUnit, selectThreeDState } from '../slices/graphSlice'; import { omit } from 'lodash'; +import { selectLineChartDeps } from './lineChartSelectors'; // query args that 'most' graphs share export interface commonQueryArgs { @@ -25,6 +26,7 @@ export interface commonQueryArgs { // endpoint specific args export interface LineReadingApiArgs extends commonQueryArgs { } +export interface CompareLineReadingApiArgs extends commonQueryArgs { } export interface BarReadingApiArgs extends commonQueryArgs { barWidthDays: number } // ThreeD only queries a single id so extend common, but omit ids array @@ -81,6 +83,32 @@ export const selectLineChartQueryArgs = createSelector( } ); +export const selectCompareLineQueryArgs = createSelector( + selectQueryTimeInterval, + selectSelectedUnit, + selectThreeDState, + selectLineChartDeps, + (queryTimeInterval, selectedUnit, threeD, lineChartDeps) => { + const args: CompareLineReadingApiArgs = + threeD.meterOrGroup === MeterOrGroup.meters + ? { + ids: [threeD.meterOrGroupID!], + timeInterval: queryTimeInterval.toString(), + graphicUnitId: selectedUnit, + meterOrGroup: threeD.meterOrGroup! + } + : { + ids: [threeD.meterOrGroupID!], + timeInterval: queryTimeInterval.toString(), + graphicUnitId: selectedUnit, + meterOrGroup: threeD.meterOrGroup! + }; + const shouldSkipQuery = !threeD.meterOrGroupID || !queryTimeInterval.getIsBounded(); + const argsDeps = threeD.meterOrGroup === MeterOrGroup.meters ? lineChartDeps.meterDeps : lineChartDeps.groupDeps; + return { args, shouldSkipQuery, argsDeps }; + } +); + export const selectRadarChartQueryArgs = createSelector( selectLineChartQueryArgs, lineChartArgs => { @@ -185,11 +213,13 @@ export const selectAllChartQueryArgs = createSelector( selectCompareChartQueryArgs, selectMapChartQueryArgs, selectThreeDQueryArgs, - (line, bar, compare, map, threeD) => ({ + selectCompareLineQueryArgs, + (line, bar, compare, map, threeD, compareLine) => ({ line, bar, compare, map, - threeD + threeD, + compareLine }) ); diff --git a/src/client/app/redux/selectors/lineChartSelectors.ts b/src/client/app/redux/selectors/lineChartSelectors.ts index d4ca92247..b69ec7b28 100644 --- a/src/client/app/redux/selectors/lineChartSelectors.ts +++ b/src/client/app/redux/selectors/lineChartSelectors.ts @@ -45,6 +45,7 @@ export const selectPlotlyMeterData = selectFromLineReadingsResult( const yMinData: number[] = []; const yMaxData: number[] = []; const hoverText: string[] = []; + // The scaling is the factor to change the reading by. It divides by the area while will be 1 if no scaling by area. readings.forEach(reading => { // As usual, we want to interpret the readings in UTC. We lose the timezone as this as the start/endTimestamp diff --git a/src/client/app/redux/selectors/uiSelectors.ts b/src/client/app/redux/selectors/uiSelectors.ts index a0089af84..7ecb1b522 100644 --- a/src/client/app/redux/selectors/uiSelectors.ts +++ b/src/client/app/redux/selectors/uiSelectors.ts @@ -8,7 +8,7 @@ import { selectUnitDataById } from '../../redux/api/unitsApi'; import { selectChartLinkHideOptions, selectSelectedLanguage } from '../../redux/slices/appStateSlice'; import { DataType } from '../../types/Datasources'; import { GroupedOption, SelectOption } from '../../types/items'; -import { ChartTypes } from '../../types/redux/graph'; +import { ChartTypes, ShiftAmount } from '../../types/redux/graph'; import { GroupDataByID } from '../../types/redux/groups'; import { MeterDataByID } from '../../types/redux/meters'; import { UnitDataById, UnitRepresentType } from '../../types/redux/units'; @@ -486,6 +486,12 @@ export const selectChartLink = createAppSelector( linkText += `&meterOrGroupID=${current.threeD.meterOrGroupID}`; linkText += `&readingInterval=${current.threeD.readingInterval}`; break; + case ChartTypes.compareLine: + linkText += `&meterOrGroup=${current.threeD.meterOrGroup}`; + linkText += `&meterOrGroupID=${current.threeD.meterOrGroupID}`; + linkText += `&shiftAmount=${current.shiftAmount}`; + current.shiftAmount === ShiftAmount.custom && (linkText += `&shiftTimeInterval=${current.shiftTimeInterval}`); + break; } const unitID = current.selectedUnit; linkText += `&unitID=${unitID.toString()}`; diff --git a/src/client/app/redux/slices/graphSlice.ts b/src/client/app/redux/slices/graphSlice.ts index 91e38edd2..d0a040523 100644 --- a/src/client/app/redux/slices/graphSlice.ts +++ b/src/client/app/redux/slices/graphSlice.ts @@ -13,7 +13,7 @@ import { updateHistory, updateSliderRange } from '../../redux/actions/extraActions'; import { SelectOption } from '../../types/items'; -import { ChartTypes, GraphState, LineGraphRate, MeterOrGroup, ReadingInterval } from '../../types/redux/graph'; +import { ChartTypes, GraphState, LineGraphRate, MeterOrGroup, ReadingInterval, ShiftAmount } from '../../types/redux/graph'; import { ComparePeriod, SortingOrder, calculateCompareTimeInterval, validateComparePeriod, validateSortingOrder } from '../../utils/calculateCompare'; import { AreaUnitType } from '../../utils/getAreaUnitConversion'; import { preferencesApi } from '../api/preferencesApi'; @@ -39,7 +39,9 @@ const defaultState: GraphState = { meterOrGroup: undefined, readingInterval: ReadingInterval.Hourly }, - hotlinked: false + hotlinked: false, + shiftAmount: ShiftAmount.none, + shiftTimeInterval: TimeInterval.unbounded() }; interface History { @@ -88,6 +90,17 @@ export const graphSlice = createSlice({ state.current.queryTimeInterval = action.payload; } }, + updateShiftTimeInterval: (state, action: PayloadAction) => { + // same as updateTimeInterval, always update if action is bounded, + // else only set unbounded if current isn't already unbounded. + // clearing when already unbounded should be a no-op + if (action.payload.getIsBounded() || state.current.shiftTimeInterval.getIsBounded()) { + state.current.shiftTimeInterval = action.payload; + } + }, + updateShiftAmount: (state, action: PayloadAction) => { + state.current.shiftAmount = action.payload; + }, changeSliderRange: (state, action: PayloadAction) => { if (action.payload.getIsBounded() || state.current.rangeSliderInterval.getIsBounded()) { state.current.rangeSliderInterval = action.payload; @@ -347,6 +360,12 @@ export const graphSlice = createSlice({ case 'unitID': current.selectedUnit = parseInt(value); break; + case 'shiftAmount': + current.shiftAmount = value as ShiftAmount; + break; + case 'shiftTimeInterval': + current.shiftTimeInterval = TimeInterval.fromString(value); + break; } }); } @@ -387,7 +406,9 @@ export const graphSlice = createSlice({ selectHistoryIsDirty: state => state.prev.length > 0 || state.next.length > 0, selectSliderRangeInterval: state => state.current.rangeSliderInterval, selectPlotlySliderMin: state => state.current.rangeSliderInterval.getStartTimestamp()?.utc().toDate().toISOString(), - selectPlotlySliderMax: state => state.current.rangeSliderInterval.getEndTimestamp()?.utc().toDate().toISOString() + selectPlotlySliderMax: state => state.current.rangeSliderInterval.getEndTimestamp()?.utc().toDate().toISOString(), + selectShiftAmount: state => state.current.shiftAmount, + selectShiftTimeInterval: state => state.current.shiftTimeInterval } }); @@ -405,7 +426,8 @@ export const { selectThreeDMeterOrGroupID, selectThreeDReadingInterval, selectGraphAreaNormalization, selectSliderRangeInterval, selectDefaultGraphState, selectHistoryIsDirty, - selectPlotlySliderMax, selectPlotlySliderMin + selectPlotlySliderMax, selectPlotlySliderMin, + selectShiftAmount, selectShiftTimeInterval } = graphSlice.selectors; // actionCreators exports @@ -422,6 +444,7 @@ export const { toggleAreaNormalization, updateThreeDMeterOrGroup, changeCompareSortingOrder, updateThreeDMeterOrGroupID, updateThreeDReadingInterval, updateThreeDMeterOrGroupInfo, - updateSelectedMetersOrGroups + updateSelectedMetersOrGroups, updateShiftAmount, + updateShiftTimeInterval } = graphSlice.actions; diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index fe64e0f3c..6a84c1256 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -7,6 +7,11 @@ // This file used to be a json file, but had issues with importing, so we declared the json variable in a js file instead. const LocaleTranslationData = { "en": { + "1.day": "1 day", + "1.month": "1 month", + "1.week": "1 week", + "1.year": "1 year", + "2.months": "2 months", "3D": "3D", "4.weeks": "4 Weeks", "400": "400 Bad Request", @@ -47,7 +52,9 @@ const LocaleTranslationData = { "clipboard.copied": "Copied To Clipboard", "clipboard.not.copied": "Failed to Copy To Clipboard", "close": "Close", - "compare": "Compare", + "compare.bar": "Compare bar", + "compare.line": "Compare line", + "compare.line.days.enter": "Enter in days and then hit enter", "compare.period": "Compare Period", "compare.raw": "Cannot create comparison graph on raw units such as temperature", "confirm.action": "Confirm Action", @@ -111,6 +118,7 @@ const LocaleTranslationData = { "csv.upload.readings": "Upload Readings", "csvMeters": "CSV Meters", "csvReadings": "CSV Readings", + "custom.date.range": "Custom date range", "custom.value": "Custom value", "date.range": 'Date Range', "day": "Day", @@ -394,6 +402,7 @@ const LocaleTranslationData = { "oed.description": "Open Energy Dashboard is an independent open source project. ", "oed.version": "OED version ", "options": "Options", + "original.data.crosses.leap.year.to.non.leap.year": "Original data crosses a leap year so the graph might not align appropriately", "page.choice.login": "Page choices & login", "page.choice.logout": "Page choices & logout", "page.restart.button": "Restart OED session", @@ -404,6 +413,7 @@ const LocaleTranslationData = { "per.hour": "Per Hour", "per.minute": "Per Minute", "per.second": "Per Second", + "please.set.the.date.range": "Please choose date range", "projected.to.be.used": "projected to be used", "radar": "Radar", "radar.lines.incompatible": "These meters/groups are not compatible for radar graphs", @@ -434,7 +444,10 @@ const LocaleTranslationData = { "select.meter.group": "Select meter or group to graph", "select.meter.type": "Select Meter Type", "select.meters": "Select Meters", + "select.shift.amount": "Select shift amount", "select.unit": "Select Unit", + "shift.date.interval": "Shift Date Interval", + "shifted.data.crosses.leap.year.to.non.leap.year": "Shifted data crosses a leap year so the graph might not align appropriately", "show": "Show", "show.all.logs": "Show All Logs ", "show.grid": "Show grid", @@ -496,10 +509,10 @@ const LocaleTranslationData = { "UnitRepresentType.quantity": "quantity", "UnitRepresentType.raw": "raw", "units": "Units", - "units.conversion.page.title": "Units and Conversions Visual Graphics", "UnitType.meter": "meter", "UnitType.suffix": "suffix", "UnitType.unit": "unit", + "units.conversion.page.title": "Units and Conversions Visual Graphics", "unsaved.failure": "Changes failed to save", "unsaved.success": "Changes saved", "unsaved.warning": "You have unsaved change(s). Are you sure you want to leave?", @@ -541,6 +554,11 @@ const LocaleTranslationData = { "you.cannot.create.a.cyclic.group": "You cannot create a cyclic group", }, "fr": { + "1.day": "1 day\u{26A1}", + "1.month": "1 month\u{26A1}", + "1.week": "1 week\u{26A1}", + "1.year": "1 year\u{26A1}", + "2.months": "2 months\u{26A1}", "3D": "3D", "4.weeks": "4 Semaines", "400": "400 Bad Request\u{26A1}", @@ -581,7 +599,9 @@ const LocaleTranslationData = { "clipboard.copied": "Copied To Clipboard\u{26A1}", "clipboard.not.copied": "Failed to Copy To Clipboard\u{26A1}", "close": "Close\u{26A1}", - "compare": "Comparer", + "compare.bar": "Compare Bar\u{26A1}", + "compare.line": "Compare line\u{26A1}", + "compare.line.days.enter": "Enter in days and then hit enter\u{26A1}", "compare.period": "Compare Period\u{26A1}", "compare.raw": "Cannot create comparison graph on raw units such as temperature\u{26A1}", "confirm.action": "Confirm Action\u{26A1}", @@ -643,6 +663,7 @@ const LocaleTranslationData = { "csv.tab.readings": "Lectures", "csv.upload.meters": "Téléverser Mètres", "csv.upload.readings": "Téléverser Lectures", + "custom.date.range": "Custom date range\u{26A1}", "csvMeters": "CSV Meters\u{26A1}", "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Custom value\u{26A1}", @@ -928,6 +949,7 @@ const LocaleTranslationData = { "oed.description": "Le Tableau de Bord Ouvert d'énergie est un projet open source indépendant. ", "oed.version": "OED version \u{26A1}", "options": "Options", + "original.data.crosses.leap.year.to.non.leap.year": "Original data crosses a leap year so the graph might not align appropriately\u{26A1}", "page.choice.login": "Page choices & login\u{26A1}", "page.choice.logout": "Page choices & logout\u{26A1}", "page.restart.button": "Restart OED session\u{26A1}", @@ -938,6 +960,7 @@ const LocaleTranslationData = { "per.hour": "Per Hour\u{26A1}", "per.minute": "Per Minute\u{26A1}", "per.second": "Per Second\u{26A1}", + "please.set.the.date.range": "Please choose date range\u{26A1}", "projected.to.be.used": "projeté pour être utilisé", "radar": "Radar", "radar.lines.incompatible": "These meters/groups are not compatible for radar graphs\u{26A1}", @@ -968,7 +991,10 @@ const LocaleTranslationData = { "select.meter.group": "Select meter or group to graph\u{26A1}", "select.meter.type": "Select Meter Type\u{26A1}", "select.meters": "Sélectionnez des Mètres", + "select.shift.amount": "Select shift amount\u{26A1}", "select.unit": "Select Unit\u{26A1}", + "shift.date.interval": "Shift Date Interval\u{26A1}", + "shifted.data.crosses.leap.year.to.non.leap.year": "Shifted data crosses a leap year so the graph might not align appropriately\u{26A1}", "show": "Montrer", "show.all.logs": "Show All Logs\u{26A1} ", "show.grid": "Show grid\u{26A1}", @@ -1027,10 +1053,10 @@ const LocaleTranslationData = { "UnitRepresentType.quantity": "quantity\u{26A1}", "UnitRepresentType.raw": "raw\u{26A1}", "units": "Units\u{26A1}", - "units.conversion.page.title": "Units and Conversions Visual Graphics\u{26A1}", "UnitType.meter": "meter\u{26A1}", "UnitType.suffix": "suffix\u{26A1}", "UnitType.unit": "unit\u{26A1}", + "units.conversion.page.title": "Units and Conversions Visual Graphics\u{26A1}", "unsaved.failure": "Changes failed to save\u{26A1}", "unsaved.success": "Changes saved\u{26A1}", "unsaved.warning": "You have unsaved change(s). Are you sure you want to leave?\u{26A1}", @@ -1075,6 +1101,11 @@ const LocaleTranslationData = { 'threeD.y.axis.label': 'Jours de l\'année calendaire', }, "es": { + "1.day": "1 day\u{26A1}", + "1.month": "1 month\u{26A1}", + "1.week": "1 week\u{26A1}", + "1.year": "1 year\u{26A1}", + "2.months": "2 months\u{26A1}", "3D": "3D", "4.weeks": "4 Semanas", "400": "400 Solicitud incorrecta", @@ -1115,7 +1146,9 @@ const LocaleTranslationData = { "clipboard.copied": "Copiado al portapapeles", "clipboard.not.copied": "Error al copiar al portapapeles", "close": "Cerrar", - "compare": "Comparar", + "compare.bar": "Compare bar\u{26A1}", + "compare.line": "Compare line\u{26A1}", + "compare.line.days.enter": "Enter in days and then hit enter\u{26A1}", "compare.period": "Compare Period\u{26A1}", "compare.raw": "No se puede crear un gráfico de comparación con unidades crudas como temperatura", "confirm.action": "Confirmar acción", @@ -1177,6 +1210,7 @@ const LocaleTranslationData = { "csv.tab.readings": "Lecturas", "csv.upload.meters": "Subir medidores CSV", "csv.upload.readings": "Subir lecturas CSV", + "custom.date.range": "Custom date range\u{26A1}", "csvMeters": "CSV Meters\u{26A1}", "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Valor personalizado", @@ -1463,6 +1497,7 @@ const LocaleTranslationData = { "oed.description": "Open Energy Dashboard es un proyecto independiente. ", "oed.version": "Versión OED", "options": "Opciones", + "original.data.crosses.leap.year.to.non.leap.year": "Original data crosses a leap year so the graph might not align appropriately\u{26A1}", "page.choice.login": "Selección de página y inicio de sesión", "page.choice.logout": "Selección de página y fin de sesión", "page.restart.button": "Restart OED session\u{26A1}", @@ -1473,6 +1508,7 @@ const LocaleTranslationData = { "per.hour": "Por hora", "per.minute": "Por minuto", "per.second": "Por segundo", + "please.set.the.date.range": "Please choose date range\u{26A1}", "projected.to.be.used": "proyectado para ser utilizado", "radar": "Radar", "radar.lines.incompatible": "Estos medidores/grupos no son compatibles para gráficos de radares", @@ -1503,7 +1539,10 @@ const LocaleTranslationData = { "select.meter.group": "Seleccionar medidor o grupo para hacer gráfico", "select.meter.type": "Seleccionar tipo de medidor", "select.meters": "Seleccionar medidores", + "select.shift.amount": "Select shift amount\u{26A1}", "select.unit": "Seleccionar unidad", + "shift.date.interval": "Shift Date Interval\u{26A1}", + "shifted.data.crosses.leap.year.to.non.leap.year": "Shifted data crosses a leap year so the graph might not align appropriately\u{26A1}", "show": "Mostrar", "show.all.logs": "Show All Logs\u{26A1} ", "show.grid": "Mostrar rejilla", @@ -1565,10 +1604,10 @@ const LocaleTranslationData = { "UnitRepresentType.quantity": "cantidad", "UnitRepresentType.raw": "crudo", "units": "Unidades", - "units.conversion.page.title": "Gráficos Visuales de Unidades y Conversiones", "UnitType.meter": "medidor", "UnitType.suffix": "sufijo", "UnitType.unit": "unidad", + "units.conversion.page.title": "Gráficos Visuales de Unidades y Conversiones", "unsaved.failure": "No se pudieron guardar los cambios", "unsaved.success": "Se guardaron los cambios", "unsaved.warning": "Tienes cambios sin guardar. ¿Estás seguro que quieres salir?", diff --git a/src/client/app/types/redux/graph.ts b/src/client/app/types/redux/graph.ts index 601dd51b9..7250e05eb 100644 --- a/src/client/app/types/redux/graph.ts +++ b/src/client/app/types/redux/graph.ts @@ -10,10 +10,11 @@ import { AreaUnitType } from '../../utils/getAreaUnitConversion'; export enum ChartTypes { line = 'line', bar = 'bar', - compare = 'compare', + compare = 'compare.bar', map = 'map', radar = 'radar', - threeD = '3D' + threeD = '3D', + compareLine = 'compare.line' } // Rates that can be graphed, only relevant to line graphs. @@ -55,6 +56,15 @@ export interface ThreeDState { readingInterval: ReadingInterval; } +export enum ShiftAmount { + none = 'none', + day = 'day', + week = 'week', + month = 'month', + year = 'year', + custom = 'custom' +} + export interface GraphState { areaNormalization: boolean; selectedMeters: number[]; @@ -73,4 +83,6 @@ export interface GraphState { threeD: ThreeDState; queryTimeInterval: TimeInterval; hotlinked: boolean; + shiftAmount: ShiftAmount; + shiftTimeInterval: TimeInterval; }