This repository has been archived on 2026-05-31. You can view files and clone it, but cannot push or open issues or pull requests.

571 lines
24 KiB
QML

import QtQuick 2.3
import QtQuick.Layouts 1.2
import QtQuick.Controls 2.2
import QtCharts 2.3
import Nymea 1.0
import "qrc:/ui/components/"
StatsBase {
id: root
property EnergyManager energyManager: null
property ThingsProxy consumers: null
property bool titleVisible: true
QtObject {
id: d
property var config: root.configs[selectionTabs.currentValue.config]
property int startOffset: 0
property Thing selectedThing: null
property date startTime: root.calculateTimestamp(config.startTime(), config.sampleRate, startOffset)
property date endTime: root.calculateTimestamp(config.startTime(), config.sampleRate, startOffset + config.count)
property bool fetchPending: false
property bool loading: d.fetchPending || wheelStopTimer.running || logsLoader.fetchingData
onConfigChanged: {
for (var i = 0; i < consumersRepeater.count; i++) {
consumersRepeater.itemAt(i).refreshLabels()
}
valueAxis.max = 1
}
onLoadingChanged: {
if (!loading) {
refresh()
}
}
function refresh() {
for (var i = 0; i < consumersRepeater.count; i++) {
consumersRepeater.itemAt(i).refresh()
}
}
function selectThing(thing) {
if (d.selectedThing === thing) {
d.selectedThing = null
} else {
d.selectedThing = thing
}
}
}
ThingPowerLogsLoader {
id: logsLoader
engine: _engine
startTime: root.calculateTimestamp(d.startTime, d.config.sampleRate, -d.config.count)
endTime: root.calculateTimestamp(d.startTime, d.config.sampleRate, d.config.count)
sampleRate: d.config.sampleRate
onFetchingDataChanged: {
if (!fetchingData) {
d.fetchPending = false
}
}
}
Repeater {
id: consumersRepeater
model: !engine.thingManager.fetchingData && !engine.tagsManager.busy ? root.consumers : null
onCountChanged: {
if (count == root.consumers.count) {
logsLoader.fetchLogs();
}
}
delegate: Item {
id: consumerDelegate
readonly property Thing thing: root.consumers.get(index)
property BarSet barSet: null
Connections {
target: d
onStartOffsetChanged: refresh()
}
function refreshLabels() {
var values = []
for (var i = 0; i < d.config.count; i++) {
values.push(0)
}
barSet.values = values;
}
function refresh() {
var upcomingTimestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.config.count)
// print("refreshing", consumerDelegate.thing.name ,"config start", d.config.startTime(), "upcoming:", upcomingTimestamp, "fetchPending", d.fetchPending, d.loading)
for (var i = 0; i < d.config.count; i++) {
var timestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + i + 1)
var previousTimestamp = root.calculateTimestamp(timestamp, d.config.sampleRate, -1)
// print("timestamp:", timestamp, "previous:", previousTimestamp)
var entry = thingPowerLogs.find(timestamp)
var previousEntry = thingPowerLogs.find(previousTimestamp);
if (timestamp < upcomingTimestamp && entry && (previousEntry || !d.loading)) {
// print("found entry:", entry.timestamp, previousEntry)
var consumption = entry.totalConsumption
if (previousEntry) {
consumption -= previousEntry.totalConsumption
}
barSet.replace(i, consumption)
valueAxis.adjustMax(consumption)
} else if (timestamp.getTime() == upcomingTimestamp.getTime() && (previousEntry || !d.loading)) {
var consumption = thingPowerLogs.liveEntry().totalConsumption
// print("it's today for thing", thing.name, consumption, previousEntry)
if (previousEntry) {
// print("previous timestamp", previousEntry.timestamp, previousEntry.totalConsumption)
consumption -= previousEntry.totalConsumption
}
barSet.replace(i, consumption)
valueAxis.adjustMax(consumption)
} else {
barSet.replace(i, 0)
}
}
}
readonly property ThingPowerLogs logs: ThingPowerLogs {
id: thingPowerLogs
engine: _engine
startTime: root.calculateTimestamp(d.startTime, d.config.sampleRate, -d.config.count)
endTime: root.calculateTimestamp(d.startTime, d.config.sampleRate, d.config.count)
thingId: consumerDelegate.thing.id
sampleRate: d.config.sampleRate
loader: logsLoader
onFetchingDataChanged: {
if (fetchingData) {
return;
}
consumerDelegate.refresh()
}
}
Component.onCompleted: {
var values = []
for (var i = 0; i < d.config.count; i++) {
values.push(0)
}
barSet = barSeries.append(consumerDelegate.thing.name, values)
barSet.color = Qt.binding(function() {
return NymeaUtils.generateColor(Style.generationBaseColor, index, d.selectedThing == null || consumerDelegate.thing == d.selectedThing ? 1 : 0.3)
})
barSet.borderColor = Qt.binding(function(){ return barSet.color})
barSet.borderWidth = 0
}
Component.onDestruction: {
barSeries.remove(barSet)
}
}
}
ColumnLayout {
anchors.fill: parent
spacing: 0
Label {
Layout.fillWidth: true
Layout.margins: Style.smallMargins
horizontalAlignment: Text.AlignHCenter
text: qsTr("Consumers totals")
visible: root.titleVisible
MouseArea {
anchors.fill: parent
onClicked: pageStack.push(Qt.resolvedUrl("ConsumerStatsPage.qml"), {energyManager: root.energyManager, consumers: root.consumers})
}
}
SelectionTabs {
id: selectionTabs
Layout.fillWidth: true
Layout.leftMargin: Style.smallMargins
Layout.rightMargin: Style.smallMargins
currentIndex: 1
model: ListModel {
ListElement { modelData: qsTr("Hours"); config: "hours" }
ListElement { modelData: qsTr("Days"); config: "days" }
ListElement { modelData: qsTr("Weeks"); config: "weeks" }
ListElement { modelData: qsTr("Months"); config: "months" }
ListElement { modelData: qsTr("Years"); config: "years" }
// ListElement { modelData: qsTr("Minutes"); config: "minutes" }
}
onTabSelected: {
d.startOffset = 0
logsLoader.fetchLogs();
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
x: chartView.x + chartView.plotArea.x + (chartView.plotArea.width - width) / 2
y: chartView.y + chartView.plotArea.y + Style.smallMargins
text: d.config.toRangeLabel(d.startTime)
font: Style.smallFont
opacity: d.startOffset < -d.config.count ? .5 : 0
Behavior on opacity { NumberAnimation {} }
}
ChartView {
id: chartView
anchors.fill: parent
backgroundColor: "transparent"
// margins.left: 0
margins.right: 0
margins.top: 0
margins.bottom: Style.smallIconSize + Style.margins
legend.visible: false
legend.alignment: Qt.AlignBottom
legend.font: Style.extraSmallFont
legend.labelColor: Style.foregroundColor
ActivityIndicator {
x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2
y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8)
visible: logsLoader.fetchingData
opacity: .5
}
Label {
x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2
y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8)
text: qsTr("No data available")
opacity: {
if (logsLoader.fetchingData || d.startOffset == 0) {
return 0
}
var oldestEntry = new Date().getTime();
var haveItems = false;
for (var i = 0; i < consumersRepeater.count; i++) {
var logsModel = consumersRepeater.itemAt(i).logs
var firstEntry = logsModel.get(0)
if (firstEntry) {
haveItems = true;
oldestEntry = Math.min(oldestEntry, firstEntry.timestamp.getTime())
}
}
if (!haveItems || oldestEntry >= d.endTime.getTime()) {
return 0.5
}
return 0;
}
font: Style.smallFont
Behavior on opacity { NumberAnimation {}}
}
Item {
id: labelsLayout
x: Style.smallMargins
y: chartView.plotArea.y
height: chartView.plotArea.height
width: chartView.plotArea.x - x
Repeater {
model: valueAxis.tickCount
delegate: Label {
y: parent.height / (valueAxis.tickCount - 1) * index - font.pixelSize / 2
width: parent.width - Style.smallMargins
horizontalAlignment: Text.AlignRight
text: ((valueAxis.max - (index * valueAxis.max / (valueAxis.tickCount - 1)))).toFixed(1) + "kWh"
verticalAlignment: Text.AlignTop
font: Style.extraSmallFont
color: Style.foregroundColor
}
}
}
BarSeries {
id: barSeries
axisX: BarCategoryAxis {
id: categoryAxis
labelsColor: Style.foregroundColor
labelsFont: Style.extraSmallFont
gridVisible: false
gridLineColor: Style.tileOverlayColor
lineVisible: false
titleVisible: false
shadesVisible: false
categories: {
var ret = []
for (var i = 0; i < d.config.count; i++) {
var timestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + i);
ret.push(d.config.toLabel(timestamp))
}
return ret;
}
}
axisY: ValueAxis {
id: valueAxis
min: 0
gridLineColor: Style.tileOverlayColor
labelsVisible: false
labelsColor: Style.foregroundColor
labelsFont: Style.extraSmallFont
lineVisible: false
titleVisible: false
shadesVisible: false
function adjustMax(newValue) {
if (max < newValue) {
max = Math.ceil(newValue)
}
}
}
}
}
RowLayout {
id: legend
anchors { left: parent.left; bottom: parent.bottom; right: parent.right }
anchors.leftMargin: chartView.plotArea.x
height: Style.smallIconSize
anchors.margins: Style.margins
Repeater {
model: root.consumers
delegate: MouseArea {
id: legendDelegate
Layout.fillWidth: true
Layout.fillHeight: true
readonly property Thing thing: root.consumers.get(index)
onClicked: d.selectThing(thing)
Row {
anchors.centerIn: parent
spacing: Style.smallMargins
ColorIcon {
name: app.interfacesToIcon(legendDelegate.thing.thingClass.interfaces)
size: Style.smallIconSize
color: index >= 0 ? NymeaUtils.generateColor(Style.generationBaseColor, index) : "white"
}
Label {
text: legendDelegate.thing.name
width: Math.max(0, legendDelegate.width - x)
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
font: Style.smallFont
visible: legend.width / root.consumers.count >= 80
}
}
}
}
}
Item {
anchors.fill: parent
anchors.leftMargin: chartView.x + chartView.plotArea.x
anchors.topMargin: chartView.y + chartView.plotArea.y
anchors.rightMargin: chartView.width - chartView.plotArea.width - chartView.plotArea.x
anchors.bottomMargin: chartView.height - chartView.plotArea.height - chartView.plotArea.y
z: -1
Rectangle {
height: parent.height + Style.margins * 2
y: -Style.smallMargins
radius: Style.smallCornerRadius
width: chartView.plotArea.width / categoryAxis.count
color: Style.tileBackgroundColor
property int idx: Math.max(0, Math.min(categoryAxis.count -1, Math.floor(mouseArea.mouseX * categoryAxis.count / mouseArea.width)))
visible: toolTip.visible
x: idx * parent.width / categoryAxis.count
Behavior on x { enabled: toolTip.animationsEnabled; NumberAnimation { duration: Style.animationDuration } }
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
anchors.leftMargin: chartView.x + chartView.plotArea.x
anchors.topMargin: chartView.y + chartView.plotArea.y
anchors.rightMargin: chartView.width - chartView.plotArea.width - chartView.plotArea.x
anchors.bottomMargin: chartView.height - chartView.plotArea.height - chartView.plotArea.y
hoverEnabled: true
preventStealing: tooltipping || dragging
property int startMouseX: 0
property bool dragging: false
property bool tooltipping: false
property int dragStartOffset: 0
Timer {
interval: 300
running: mouseArea.pressed
onTriggered: {
if (!mouseArea.dragging) {
mouseArea.tooltipping = true
}
}
}
onReleased: {
if (mouseArea.dragging) {
logsLoader.fetchLogs();
d.refresh()
mouseArea.dragging = false;
}
mouseArea.tooltipping = false;
}
onPressed: {
startMouseX = mouseX
dragStartOffset = d.startOffset
}
onDoubleClicked: {
var idx = Math.ceil(mouseArea.mouseX * d.config.count / mouseArea.width) - 1
var timestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + idx)
selectionTabs.currentIndex--
var startTime = d.config.startTime()
d.startOffset = (timestamp.getTime() - startTime.getTime()) / (d.config.sampleRate * 60 * 1000)
logsLoader.fetchLogs();
}
onMouseXChanged: {
if (!pressed || mouseArea.tooltipping) {
return;
}
if (Math.abs(startMouseX - mouseX) < 10) {
return;
}
dragging = true
var dragDelta = startMouseX - mouseX
var slotWidth = mouseArea.width / d.config.count
var offset = Math.floor(dragDelta / slotWidth);
d.startOffset = Math.min(dragStartOffset + offset, 0)
d.fetchPending = true;
}
property int wheelDelta: 0
onWheel: {
wheelDelta += wheel.pixelDelta.x
var slotWidth = mouseArea.width / d.config.count
while (wheelDelta > slotWidth) {
d.startOffset--
wheelDelta -= slotWidth
}
while (wheelDelta < -slotWidth) {
d.startOffset = Math.min(d.startOffset + 1, 0)
wheelDelta += slotWidth
}
d.fetchPending = true;
wheelStopTimer.restart()
}
Timer {
id: wheelStopTimer
interval: 300
repeat: false
onTriggered: {
logsLoader.fetchLogs()
d.refresh()
}
}
NymeaToolTip {
id: toolTip
backgroundItem: chartView
backgroundRect: Qt.rect(chartView.plotArea.x + toolTip.x, chartView.plotArea.y + toolTip.y, toolTip.width, toolTip.height)
property int idx: visible ? Math.max(0, Math.min(d.config.count - 1, Math.ceil(mouseArea.mouseX * d.config.count / mouseArea.width) - 1)) : 0
property date timestamp: root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + idx)
visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging
property int chartWidth: chartView.plotArea.width
property int barWidth: chartWidth / categoryAxis.count
x: chartWidth - (idx * barWidth + barWidth + Style.smallMargins) > width ?
idx * barWidth + barWidth + Style.smallMargins
: idx * barWidth - Style.smallMargins - width
property double setMaxValue: {
var max = 0;
for (var i = 0; i < consumersRepeater.count; i++) {
max = Math.max(max, consumersRepeater.itemAt(i).barSet.at(idx))
}
return max
}
y: Math.min(Math.max(mouseArea.height - (setMaxValue * mouseArea.height / valueAxis.max) - height - Style.smallMargins, 0), mouseArea.height - height)
width: tooltipLayout.implicitWidth + Style.smallMargins * 2
height: tooltipLayout.implicitHeight + Style.smallMargins * 2
ColumnLayout {
id: tooltipLayout
anchors {
left: parent.left
top: parent.top
margins: Style.smallMargins
}
Label {
text: d.config.toLongLabel(toolTip.timestamp)
font: Style.smallFont
}
Repeater {
model: ListModel {
id: toolTipModel
property var entries: {
var unsorted = []
for (var i = 0; i < consumersRepeater.count; i++) {
var consumerDelegate = consumersRepeater.itemAt(i)
var consumer = consumerDelegate.thing
var entry = {
consumer: consumer,
value: consumersRepeater.itemAt(i).barSet.at(toolTip.idx).toFixed(2),
indexInModel: i
}
unsorted.push(entry)
}
return unsorted
}
onEntriesChanged: {
clear();
var unsorted = entries;
for (var i = 0; i < unsorted.length; i++) {
var j = 0;
while (j < count && get(j).value > unsorted[i].value) {
j++;
}
insert(j, unsorted[i])
}
}
}
delegate: RowLayout {
opacity: d.selectedThing == null || d.selectedThing === model.consumer ? 1 : 0.3
Rectangle {
width: Style.extraSmallFont.pixelSize
height: width
color: NymeaUtils.generateColor(Style.generationBaseColor, model.indexInModel)
}
Label {
text: "%1: %2 kWh".arg(model.consumer.name).arg(model.value)
font: Style.extraSmallFont
}
}
}
}
}
}
}
}
}