Repository navigation
Fix bar chart navigation between dates #3240
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
Open
alexsmolya
wants to merge
10
commits into
openremote:main
Choose a base branch
from
alexsmolya:fix/bar-chart-date-navigation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+404
−36
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
ef076db
Fix bar chart timeframe navigation drift
alexsmolya 8028764
Keep bar chart axis bounds tied to timeframe
alexsmolya 1b12a7b
Preserve calendar semantics for chart navigation
alexsmolya ec01c9a
Harden bar chart navigation review regressions
alexsmolya 1186db2
Exercise production chart bounds ownership
alexsmolya aa54c7c
Fix bar chart build errors
alexsmolya 85ec4a3
Fix bar chart component test validation
alexsmolya b0d4c4a
Fix bar chart navigation review regressions
alexsmolya 4abeca2
fix(bar-chart): preserve configured navigation duration
alexsmolya d08ea61
fix(bar-chart): preserve custom timeframe duration
alexsmolya 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
This file contains hidden or 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
This file contains hidden or 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
This file contains hidden or 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,21 @@ | ||
| /* | ||
| * Copyright 2026, OpenRemote Inc. | ||
| * | ||
| * This program is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU Affero General Public License as | ||
| * published by the Free Software Foundation, either version 3 of the | ||
| * License, or (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU Affero General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU Affero General Public License | ||
| * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
| * | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
| import { defineCtConfig } from "@openremote/test/component.config"; | ||
|
|
||
| export default defineCtConfig(__dirname, { use: { timezoneId: "Europe/Stockholm" } }); | ||
This file contains hidden or 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 | ||
|---|---|---|---|---|
|
|
@@ -55,6 +55,7 @@ import { createMenuBarItem, type MenuBarItem } from "@openremote/or-vaadin-compo | |||
| import { OrVaadinDateTimePicker } from "@openremote/or-vaadin-components/or-vaadin-date-time-picker"; | ||||
| import { when } from "lit/directives/when.js"; | ||||
| import { createRef, type Ref, ref } from "lit/directives/ref.js"; | ||||
| import { getChartAxisBounds, getNavigationDuration, shiftTimeframe, type TimeframeDirection } from "./timeframe"; | ||||
| import "@openremote/or-translate"; | ||||
|
|
||||
| echarts.use([GridComponent, TooltipComponent, DataZoomComponent, BarChart, CanvasRenderer, UniversalTransition]); | ||||
|
|
@@ -502,6 +503,7 @@ export class OrAttributeBarChart extends OrElement { | |||
| protected _style!: CSSStyleDeclaration; | ||||
| protected _startOfPeriod?: number; // Start timestamp of the visible period | ||||
| protected _endOfPeriod?: number; // End timestamp of the visible period | ||||
| protected _navigationDuration?: number; | ||||
| protected _latestError?: string; | ||||
| protected _dataAbortController?: AbortController; | ||||
| protected _containerResizeObserver?: ResizeObserver; | ||||
|
|
@@ -562,6 +564,17 @@ export class OrAttributeBarChart extends OrElement { | |||
| const dates: [Date, Date] = this._getTimeSelectionDates(this.timePrefixKey!, this.timeWindowKey!); | ||||
| this._startOfPeriod = this.timeframe ? this.timeframe[0].getTime() : dates[0].getTime(); | ||||
| this._endOfPeriod = this.timeframe ? this.timeframe[1].getTime() : dates[1].getTime(); | ||||
| const timeWindow = this.timeWindowOptions.get(this.timeWindowKey!); | ||||
| if (!timeWindow) { | ||||
| throw new Error(`Unsupported time window selected: ${this.timeWindowKey}`); | ||||
| } | ||||
| this._navigationDuration = getNavigationDuration( | ||||
| this._startOfPeriod, | ||||
| this._endOfPeriod, | ||||
| this._isCustomWindow, | ||||
| timeWindow[0], | ||||
| timeWindow[1] | ||||
| ); | ||||
| this._intervalConfig = this._getInterval(this._startOfPeriod, this._endOfPeriod, this.interval!); | ||||
| this._loadData(); | ||||
| } | ||||
|
|
@@ -982,6 +995,7 @@ export class OrAttributeBarChart extends OrElement { | |||
| const recommendedTicks = this._chartElem?.clientWidth ? this._chartElem.clientWidth / 50 : Number.MAX_SAFE_INTEGER; | ||||
| const maxTicks = Math.floor(recommendedTicks * 1.5); | ||||
| const splitNumber = Math.max(1, Math.min(xAxisTicks, maxTicks)); | ||||
| const [axisMin, axisMax] = getChartAxisBounds(this._startOfPeriod!, this._endOfPeriod!, this._data); | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
| this._chart?.setOption({ | ||||
| xAxis: { | ||||
| show: splitNumber > 1, | ||||
|
|
@@ -1049,19 +1063,21 @@ export class OrAttributeBarChart extends OrElement { | |||
| return [startDate.toDate(), endDate.toDate()]; | ||||
| } | ||||
|
|
||||
| protected _shiftTimeframe(currentStart: Date, currentEnd: Date, timeWindowSelected: string, direction: string) { | ||||
| const timeWindow = this.timeWindowOptions.get(timeWindowSelected); | ||||
|
|
||||
| if (!timeWindow) { | ||||
| protected _shiftTimeframe( | ||||
| currentStart: Date, | ||||
| currentEnd: Date, | ||||
| timeWindowSelected: string, | ||||
| direction: TimeframeDirection | ||||
| ) { | ||||
| if (!this.timeWindowOptions.has(timeWindowSelected)) { | ||||
| throw new Error(`Unsupported time window selected: ${timeWindowSelected}`); | ||||
| } | ||||
|
|
||||
| const [unit, value] = timeWindow; | ||||
| const newStart = moment(currentStart); | ||||
| direction === "previous" ? newStart.subtract(value, unit) : newStart.add(value, unit); | ||||
| const newEnd = moment(currentEnd); | ||||
| direction === "previous" ? newEnd.subtract(value, unit) : newEnd.add(value, unit); | ||||
| this.timeframe = [newStart.toDate(), newEnd.toDate()]; | ||||
| const [unit, value] = this.timeWindowOptions.get(timeWindowSelected)!; | ||||
| const duration = | ||||
| this._navigationDuration ?? | ||||
| getNavigationDuration(currentStart.getTime(), currentEnd.getTime(), this._isCustomWindow, unit, value); | ||||
| this.timeframe = shiftTimeframe(currentStart, currentEnd, duration, unit, value, direction, this._isCustomWindow); | ||||
| } | ||||
|
|
||||
| protected _getInterval( | ||||
|
|
@@ -1374,34 +1390,22 @@ export class OrAttributeBarChart extends OrElement { | |||
| console.error("Could not update bar chart data; the bar chart is not initialized yet."); | ||||
| return; | ||||
| } | ||||
| // When data retrieved from HTTP API uses different start-end times, update them. | ||||
| // For example, if 'endOfPeriod' is 18:03, but the interval is 15 min, the latest API datapoint will be from 18:15. | ||||
| const firstEntry = this._data?.[0]?.data as [number, number][] | undefined; | ||||
| if (firstEntry) { | ||||
| const firstTimestamp = firstEntry[0][0]; | ||||
| if (firstTimestamp !== this._startOfPeriod) { | ||||
| this._startOfPeriod = firstTimestamp; | ||||
| } | ||||
| const endTimestamp = [...firstEntry].reverse()[0][0]; | ||||
| if (endTimestamp !== this._endOfPeriod) { | ||||
| this._endOfPeriod = endTimestamp; | ||||
| } | ||||
| } | ||||
|
|
||||
| // Update ticks / labels | ||||
| const xAxisTicks = Math.max(1, (this._endOfPeriod! - this._startOfPeriod!) / this._intervalConfig!.millis - 1); | ||||
| const recommendedTicks = this._chartElem?.clientWidth ? this._chartElem.clientWidth / 50 : Number.MAX_SAFE_INTEGER; | ||||
| const maxTicks = Math.floor(recommendedTicks * 1.5); | ||||
| const splitNumber = Math.max(1, Math.min(xAxisTicks, maxTicks)); | ||||
| const [axisMin, axisMax] = getChartAxisBounds(this._startOfPeriod!, this._endOfPeriod!, this._data); | ||||
|
|
||||
| // Update chart | ||||
| this._chart.setOption({ | ||||
| xAxis: { | ||||
| show: splitNumber > 1, | ||||
| splitNumber, | ||||
| minInterval: this._intervalConfig!.millis, | ||||
| min: this._startOfPeriod, | ||||
| max: this._endOfPeriod, | ||||
| min: axisMin, | ||||
| max: axisMax, | ||||
| axisLabel: { | ||||
| interval: this._intervalConfig?.millis, | ||||
| rotate: splitNumber > recommendedTicks ? 45 : 0, | ||||
|
|
||||
This file contains hidden or 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,74 @@ | ||||||||
| /* | ||||||||
| * Copyright 2026, OpenRemote Inc. | ||||||||
| * | ||||||||
| * This program is free software: you can redistribute it and/or modify | ||||||||
| * it under the terms of the GNU Affero General Public License as | ||||||||
| * published by the Free Software Foundation, either version 3 of the | ||||||||
| * License, or (at your option) any later version. | ||||||||
| * | ||||||||
| * This program is distributed in the hope that it will be useful, | ||||||||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||||||
| * GNU Affero General Public License for more details. | ||||||||
| * | ||||||||
| * You should have received a copy of the GNU Affero General Public License | ||||||||
| * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||||||||
| * | ||||||||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||||||||
| */ | ||||||||
| import moment from "moment"; | ||||||||
|
|
||||||||
| export type TimeframeDirection = "previous" | "next"; | ||||||||
|
|
||||||||
| export type TimeframeUnit = moment.unitOfTime.DurationConstructor; | ||||||||
|
|
||||||||
| const CALENDAR_UNITS = new Set(["day", "week", "month", "quarter", "year"]); | ||||||||
|
|
||||||||
| function normalizeUnit(unit: TimeframeUnit): string { | ||||||||
| return (moment as typeof moment & { normalizeUnits: (value: TimeframeUnit) => string }).normalizeUnits(unit); | ||||||||
| } | ||||||||
|
|
||||||||
| export function getChartAxisBounds(start: number, end: number, _dataset?: unknown): [number, number] { | ||||||||
| return [start, end]; | ||||||||
| } | ||||||||
|
|
||||||||
|
Comment on lines
+31
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function doesn't do anything. Please remove it.
Suggested change
|
||||||||
| export function getNavigationDuration( | ||||||||
| start: number, | ||||||||
| end: number, | ||||||||
| isCustomTimeframe: boolean, | ||||||||
| unit: TimeframeUnit, | ||||||||
| value: number | ||||||||
| ): number { | ||||||||
| return isCustomTimeframe ? end - start : moment.duration(value, unit).asMilliseconds(); | ||||||||
| } | ||||||||
|
|
||||||||
| export function shiftTimeframeByDuration( | ||||||||
| currentStart: Date, | ||||||||
| currentEnd: Date, | ||||||||
| duration: number, | ||||||||
| direction: TimeframeDirection | ||||||||
| ): [Date, Date] { | ||||||||
| const offset = direction === "previous" ? -duration : duration; | ||||||||
| return [new Date(currentStart.getTime() + offset), new Date(currentEnd.getTime() + offset)]; | ||||||||
| } | ||||||||
|
|
||||||||
| export function shiftTimeframe( | ||||||||
| currentStart: Date, | ||||||||
| currentEnd: Date, | ||||||||
| duration: number, | ||||||||
| unit: TimeframeUnit, | ||||||||
| value: number, | ||||||||
| direction: TimeframeDirection, | ||||||||
| isCustomTimeframe = false | ||||||||
| ): [Date, Date] { | ||||||||
| const normalizedUnit = normalizeUnit(unit); | ||||||||
| if (isCustomTimeframe || !CALENDAR_UNITS.has(normalizedUnit)) { | ||||||||
| return shiftTimeframeByDuration(currentStart, currentEnd, duration, direction); | ||||||||
| } | ||||||||
|
|
||||||||
| const start = moment(currentStart); | ||||||||
|
wborn marked this conversation as resolved.
Resolved
|
||||||||
| const end = moment(currentEnd); | ||||||||
| direction === "previous" ? start.subtract(value, unit) : start.add(value, unit); | ||||||||
| direction === "previous" ? end.subtract(value, unit) : end.add(value, unit); | ||||||||
| return [start.toDate(), end.toDate()]; | ||||||||
| } | ||||||||
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.