-
Notifications
You must be signed in to change notification settings - Fork 365
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
Merged
Merged
Compare line page #1351
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
8074066
Compare Line--color of original graph for custom is not working properly
nmqng 0bc7873
Fix custom shift date interval causing wrong data line
nmqng 180fe83
fix compare line page custom date range stay up-to-date with shift da…
nmqng 3fbb673
add notification when origin/shifted date range crosses a leap year
nmqng eeef793
change error noti to warn noti, translation key and wording
nmqng 793df8e
add warning msg when new meter/group/interval selected and rewrite ch…
nmqng 0a10a6b
Merge branch 'OpenEnergyDashboard:development' into compareLinePage
nmqng 750bad1
fix syntax, code style based on run check
nmqng f6a94a3
add license header for CompareLineControlComponent.tsx
nmqng c753331
Merge branch 'OpenEnergyDashboard:development' into compareLinePage
nmqng 42afcf3
Merge branch 'development' into compareLinePage
nmqng 865a0e3
fix merge of development issue
nmqng 58af2cf
fix some comments
nmqng 8a382c8
resolved PR comments
nmqng b3ce0a0
resolved PR comment about alphabetical order in data.ts
nmqng 501ec19
Merge branch 'development' into compareLinePage
nmqng ebc9d31
raw update on shiftDate(...) and checking reading data based on updat…
nmqng c3fec24
resolved PR comments
nmqng 78bcbc2
resolved PR comments
nmqng eb46b1b
refactor code for compare line UI
nmqng 1e2931f
refactor compare line chart and control components
nmqng 5f1eeea
remove extra es key
huss 3ea033c
resolved PR comments and refactor CompareLineControlComponent.tsx
nmqng 3caaedf
Merge branch 'development' into compareLinePage
nmqng affccd2
remove duplicate properties w same name in data.ts
nmqng fc28293
check for shift to skip getting data
huss File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
237 changes: 237 additions & 0 deletions
237
src/client/app/components/CompareLineChartComponent.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
143
src/client/app/components/CompareLineControlsComponent.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
custome misspelled.