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;
}