Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compare line page #1351

Open
wants to merge 25 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8074066
Compare Line--color of original graph for custom is not working properly
nmqng Aug 17, 2024
0bc7873
Fix custom shift date interval causing wrong data line
nmqng Sep 6, 2024
180fe83
fix compare line page custom date range stay up-to-date with shift da…
nmqng Sep 21, 2024
3fbb673
add notification when origin/shifted date range crosses a leap year
nmqng Sep 23, 2024
eeef793
change error noti to warn noti, translation key and wording
nmqng Sep 25, 2024
793df8e
add warning msg when new meter/group/interval selected and rewrite ch…
nmqng Oct 2, 2024
0a10a6b
Merge branch 'OpenEnergyDashboard:development' into compareLinePage
nmqng Oct 3, 2024
750bad1
fix syntax, code style based on run check
nmqng Oct 3, 2024
f6a94a3
add license header for CompareLineControlComponent.tsx
nmqng Oct 3, 2024
c753331
Merge branch 'OpenEnergyDashboard:development' into compareLinePage
nmqng Oct 3, 2024
42afcf3
Merge branch 'development' into compareLinePage
nmqng Oct 9, 2024
865a0e3
fix merge of development issue
nmqng Oct 14, 2024
58af2cf
fix some comments
nmqng Nov 22, 2024
8a382c8
resolved PR comments
nmqng Nov 28, 2024
b3ce0a0
resolved PR comment about alphabetical order in data.ts
nmqng Nov 28, 2024
501ec19
Merge branch 'development' into compareLinePage
nmqng Nov 28, 2024
ebc9d31
raw update on shiftDate(...) and checking reading data based on updat…
nmqng Nov 29, 2024
c3fec24
resolved PR comments
nmqng Nov 29, 2024
78bcbc2
resolved PR comments
nmqng Dec 2, 2024
eb46b1b
refactor code for compare line UI
nmqng Dec 2, 2024
1e2931f
refactor compare line chart and control components
nmqng Dec 4, 2024
5f1eeea
remove extra es key
huss Dec 6, 2024
3ea033c
resolved PR comments and refactor CompareLineControlComponent.tsx
nmqng Dec 7, 2024
3caaedf
Merge branch 'development' into compareLinePage
nmqng Dec 11, 2024
affccd2
remove duplicate properties w same name in data.ts
nmqng Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions src/client/app/components/CompareLineChartComponent.tsx
Original file line number Diff line number Diff line change
@@ -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('<br>', '<br>Shifted '))
: item.text?.replace('<br>', '<br>Shifted ')
}));

return (
<>
<ThreeDPillComponent />
{isFetching || isFetchingNew
? <SpinnerComponent loading height={50} width={50} />
: <Plot
// Only plot shifted data if the shiftAmount has been chosen
data={shiftAmount === ShiftAmount.none ? [] : [...data, ...updateDataNew]}
style={{ width: '100%', height: '100%', minHeight: '750px' }}
layout={layout}
config={{
responsive: true,
displayModeBar: false,
// Current Locale
locale,
// Available Locales
locales: Locales
}}
/>
}

</>

);

}

/**
* 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();
}
143 changes: 143 additions & 0 deletions src/client/app/components/CompareLineControlsComponent.tsx
Original file line number Diff line number Diff line change
@@ -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<TimeInterval>(shiftInterval);

// Translation for shift amount
const shiftAmountTranslations: Record<keyof typeof ShiftAmount, string> = {
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 (
<>
<div key='side-options'>
<p style={{ fontWeight: 'bold', margin: 0 }}>
<FormattedMessage id='shift.date.interval' />
<TooltipMarkerComponent page={'home'} helpTextId='help.shift.date.interval' />
</p>
<Input
id='shiftDateInput'
name='shiftDateInput'
type='select'
value={shiftAmount}
invalid={shiftAmount === ShiftAmount.none}
onChange={e => handleShiftOptionChange(e.target.value)}
>
{Object.entries(ShiftAmount).map(
([key, value]) => (
<option
hidden={value === 'none'}
disabled={value === 'none'}
value={value}
key={key}
>
{translate(shiftAmountTranslations[key as keyof typeof ShiftAmount])}
</option>
)
)}
</Input>
{/* Show date picker when custom date range is selected */}
{shiftAmount === ShiftAmount.custom &&
<DateRangePicker
value={timeIntervalToDateRange(customDateRange)}
onChange={handleCustomShiftDateChange}
minDate={new Date(1970, 0, 1)}
maxDate={new Date()}
locale={locale} // Formats Dates, and Calendar months base on locale
calendarIcon={null}
calendarProps={{ defaultView: 'year' }}
/>}
</div>
</>
);

}

/**
* 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
nmqng marked this conversation as resolved.
Show resolved Hide resolved
* @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 };
}
2 changes: 2 additions & 0 deletions src/client/app/components/DashboardComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +40,7 @@ export default function DashboardComponent() {
{chartToRender === ChartTypes.map && <MapChartComponent />}
{chartToRender === ChartTypes.threeD && <ThreeDComponent />}
{chartToRender === ChartTypes.radar && <RadarChartComponent />}
{chartToRender === ChartTypes.compareLine && <CompareLineChartComponent />}
</div>
</div>
</div>
Expand Down
Loading
Loading