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.
powersync-app/nymea-app/ui/customviews/MultiStateChart.qml

657 lines
28 KiB
QML

import QtQuick 2.9
import QtQuick.Controls 2.2
import QtQuick.Controls.Material 2.2
import QtQuick.Layouts 1.1
import Nymea 1.0
import NymeaApp.Utils 1.0
import "../components"
import "../customviews"
import QtCharts 2.2
Item {
id: root
implicitHeight: width * .6
implicitWidth: 400
// A model with roles:
// - thingId: uuid/string
// - stateName: string
// - color: color
// - fillArea: bool (default false)
property var statesModel: []
property string title: ""
QtObject {
id: d
readonly property int range: selectionTabs.currentValue.range
readonly property int sampleRate: selectionTabs.currentValue.sampleRate
readonly property int visibleValues: range / sampleRate
property date now: new Date()
readonly property var startTime: {
var date = new Date(fixTime(now));
date.setTime(date.getTime() - range * 60000 + 2000);
return date;
}
readonly property var endTime: {
var date = new Date(fixTime(now));
date.setTime(date.getTime() + 2000)
return date;
}
function fixTime(timestamp) {
return timestamp
}
function ensureValue(series, timestamp) {
if (!series) return;
if (series.count == 0) {
series.append(timestamp, 0)
} else if (series.count == 1) {
if (timestamp.getTime() < series.at(0).x) {
series.insert(0, timestamp, 0)
} else {
series.append(timestamp, 0)
}
} else {
if (timestamp.getTime() > series.at(0).x) {
series.remove(1)
series.append(timestamp, 0)
} else if (timestamp.getTime() < series.at(1).x) {
series.remove(0)
series.insert(0, timestamp, 0)
}
}
}
function shrink(series, logsModel) {
if (!series) return;
series.clear();
if (logsModel.count > 0) {
ensureValue(series, logsModel.get(0).timestamp)
ensureValue(series, logsModel.get(logsModel.count-1).timestamp)
}
}
function refreshAll() {
for (var i = 0; i < root.statesModel.length; i++) {
modelsRepeater.itemAt(i).logsModel.fetchLogs()
}
}
}
Repeater {
id: modelsRepeater
model: root.statesModel
delegate: Item {
id: modelDelegate
readonly property string thingId: root.statesModel[index].thingId
readonly property string stateName: root.statesModel[index].stateName
readonly property color color: root.statesModel[index].color
readonly property bool fillArea: root.statesModel[index].hasOwnProperty("fillArea") ? root.statesModel[index].fillArea : false
readonly property Thing thing: engine.thingManager.things.getThing(root.statesModel[index].thingId)
readonly property StateType stateType: thing.thingClass.stateTypes.findByName(stateName)
readonly property State thingState: thing.stateByName(stateName)
property XYSeries series: null
property XYSeries zeroSeries: null
property AreaSeries areaSeries: null
property ValueAxis axis: null
readonly property bool isBool: stateType.type.toLowerCase() === "bool"
Component {
id: valueAxisComponent
ValueAxis {
labelFormat: isBool ? " " : "%0.2" /*+ labelsLayout.precision*/ + "f " + Types.toUiUnit(stateType.unit)
gridLineColor: Style.tileOverlayColor
// labelsVisible: false
lineVisible: false
titleVisible: false
shadesVisible: false
labelsFont: Style.extraSmallFont
labelsColor: Style.foregroundColor
// // Overriding the labels with our own as printf struggles with special chars
// Item {
// id: labelsLayout
// x: Style.smallMargins
// y: chartView.plotArea.y
// height: chartView.plotArea.height
// width: chartView.plotArea.x - x
// visible: root.stateType.type.toLowerCase() != "bool" && logsModel.minValue != logsModel.maxValue
// property double range: Math.abs(valueAxis.max - valueAxis.min)
// property double stepSize: range / (valueAxis.tickCount - 1)
// property int precision: valueAxis.max - valueAxis.min < 5 ? 2 : 0
// Repeater {
// model: valueAxis.tickCount
// delegate: Label {
// y: parent.height / (valueAxis.tickCount - 1) * index - font.pixelSize / 2
// width: parent.width - Style.smallMargins
// horizontalAlignment: Text.AlignRight
// property double offset: (valueAxis.tickCount - index - 1) * labelsLayout.stepSize
// property double value: valueAxis.min + offset
// text: root.stateType ? Types.toUiValue(value, root.stateType.unit).toFixed(labelsLayout.precision) + " " + Types.toUiUnit(root.stateType.unit) : ""
// verticalAlignment: Text.AlignTop
// font: Style.extraSmallFont
// }
// }
// }
}
}
Component.onCompleted: {
// var axis = isBool ? boolAxis : valueAxis
axis = valueAxisComponent.createObject(chartView)
axis.min = Qt.binding(function() {return logsModel.minValue})
axis.max = Qt.binding(function() {return logsModel.maxValue})
series = chartView.createSeries(ChartView.SeriesTypeLine, thing.name, dateTimeAxis, axis)
series.color = color
series.width = isBool ? 0 : 2
if (fillArea) {
print("creating zero series")
zeroSeries = chartView.createSeries(ChartView.SeriesTypeLine, thing.name, dateTimeAxis, axis)
zeroSeries.color = color
areaSeries = chartView.createSeries(ChartView.SeriesTypeArea, thing.name, dateTimeAxis, axis)
areaSeries.upperSeries = series
areaSeries.lowerSeries = zeroSeries
areaSeries.color = Qt.rgba(color.r, color.g, color.b, color.a * .5)
areaSeries.borderColor = color
areaSeries.borderWidth = isBool ? 0 : 2
}
}
Component.onDestruction: {
if (fillArea) {
chartView.removeSeries(zeroSeries)
chartView.removeSeries(areaSeries)
}
chartView.removeSeries(series)
axis.destroy()
}
Connections {
target: selectionTabs
onTabSelected: {
logsModel.clear()
logsModel.fetchLogs()
}
}
property NewLogsModel logsModel: NewLogsModel {
// id: logsModel
engine: _engine
source: thing ? "state-" + thing.id + "-" + stateName : ""
startTime: new Date(d.startTime.getTime() - d.range * 1.1 * 60000)
endTime: new Date(d.endTime.getTime() + d.range * 1.1 * 60000)
sampleRate: stateType.type.toLowerCase() === "bool" ? NewLogsModel.SampleRateAny : d.sampleRate
sortOrder: Qt.AscendingOrder
Component.onCompleted: {
print("****** completed", modelDelegate.thingId)
ready = true
update()
}
property bool ready: false
onSourceChanged: {
// print("***** source changed")
update()
}
function update() {
// print("*********+ source", source, "start", startTime, "end", endTime, ready)
if (ready && source != "") {
fetchLogs()
}
}
property double minValue
property double maxValue
onBusyChanged: {
if (busy) {
chartView.busyCounter++
} else {
chartView.busyCounter--
}
}
onEntriesAddedIdx: {
print("**** entries added", index, count, "entries in series:", series.count, "in model", logsModel.count)
for (var i = 0; i < count; i++) {
var entry = logsModel.get(i)
print("entry", entry.timestamp, entry.source, JSON.stringify(entry.values))
d.ensureValue(zeroSeries, entry.timestamp)
if (stateType.type.toLowerCase() == "bool") {
var value = entry.values[stateType.name]
if (value == null) {
value = false;
}
value *= root.inverted ? -1 : 1
var previousEntry = i > 0 ? logsModel.get(i-1) : null;
var previousValue = previousEntry ? previousEntry.values[stateType.name] : false
if (previousValue == null) {
previousValue = false
}
// for booleans, we'll insert the previous value right before the new one so the position is doubled
var insertIdx = (index + i) * 2
// print("inserting bool 1", insertIdx, entry.timestamp.getTime() - 500, !value, new Date(entry.timestamp.getTime() - 500))
series.insert(insertIdx, entry.timestamp.getTime() - 500, previousValue)
// print("inserting bool 2", insertIdx + 1, entry.timestamp.getTime(), value, entry.timestamp)
series.insert(insertIdx+1, entry.timestamp, value)
} else {
var value = entry.values[stateType.name]
if (value == null) {
value = 0;
}
value *= root.inverted ? -1.1 : 1.1
minValue = minValue == undefined ? value : Math.min(minValue, value)
maxValue = maxValue == undefined ? value : Math.max(maxValue, value)
var insertIdx = index + i
series.insert(insertIdx, entry.timestamp, value)
}
}
if (stateType.type.toLowerCase() == "bool") {
var last = series.at(series.count-1);
if (last.x < d.endTime) {
series.append(d.endTime, last.y)
d.ensureValue(zeroSeries, d.endTime)
}
}
print("added entries. now in series:", series.count)
}
onEntriesRemoved: {
print("removing:", index, count, series.count)
if (stateType.type.toLowerCase() == "bool") {
series.removePoints(index * 2, count * 2)
if (series.count == 1) {
series.removePoints(0, 1);
}
} else {
series.removePoints(index, count)
}
d.shrink(zeroSeries, logsModel)
}
}
}
}
ColumnLayout {
anchors.fill: parent
spacing: 0
Label {
id: titleLabel
Layout.fillWidth: true
Layout.margins: Style.smallMargins
horizontalAlignment: Text.AlignHCenter
text: root.title
visible: root.title != ""
}
SelectionTabs {
id: selectionTabs
Layout.fillWidth: true
Layout.leftMargin: Style.smallMargins
Layout.rightMargin: Style.smallMargins
currentIndex: 1
model: ListModel {
ListElement {
modelData: qsTr("Hours")
sampleRate: NewLogsModel.SampleRate1Min
range: 180 // 3 Hours: 3 * 60
}
ListElement {
modelData: qsTr("Days")
sampleRate: NewLogsModel.SampleRate15Mins
range: 1440 // 1 Day: 24 * 60
}
ListElement {
modelData: qsTr("Weeks")
sampleRate: NewLogsModel.SampleRate1Hour
range: 10080 // 7 Days: 7 * 24 * 60
}
ListElement {
modelData: qsTr("Months")
sampleRate: NewLogsModel.SampleRate3Hours
range: 43200 // 30 Days: 30 * 24 * 60
}
}
onTabSelected: {
d.now = new Date()
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ChartView {
id: chartView
anchors.fill: parent
// backgroundColor: "transparent"
margins.left: 0
margins.right: 0
margins.bottom: Style.smallMargins //Style.smallIconSize + Style.margins
margins.top: 0
backgroundColor: Style.tileBackgroundColor
backgroundRoundness: Style.cornerRadius
legend.alignment: Qt.AlignBottom
legend.labelColor: Style.foregroundColor
legend.font: Style.extraSmallFont
legend.visible: false
property int busyCounter: 0
ActivityIndicator {
anchors.centerIn: parent
visible: chartView.busyCounter > 0
opacity: .5
}
Label {
anchors.centerIn: parent
visible: {
if (chartView.busyCounter > 0) {
return false
}
for (var i = 0; i < modelsRepeater.count; i++) {
if (modelsRepeater.itemAt(i).logsModel.count > 0) {
return false
}
}
return true
}
text: qsTr("No data")
font: Style.smallFont
opacity: .5
}
Label {
x: chartView.x + chartView.plotArea.x + (chartView.plotArea.width - width) / 2
y: chartView.y + chartView.plotArea.y + Style.smallMargins
text: {
switch (d.sampleRate) {
case NewLogsModel.SampleRate1Min:
return d.startTime.toLocaleDateString(Qt.locale(), Locale.LongFormat)
case NewLogsModel.SampleRate15Mins:
case NewLogsModel.SampleRate1Hour:
case NewLogsModel.SampleRate3Hours:
case NewLogsModel.SampleRate1Day:
case NewLogsModel.SampleRate1Week:
case NewLogsModel.SampleRate1Month:
case NewLogsModel.SampleRate1Year:
return d.startTime.toLocaleDateString(Qt.locale(), Locale.ShortFormat) + " - " + d.endTime.toLocaleDateString(Qt.locale(), Locale.ShortFormat)
}
}
font: Style.smallFont
opacity: ((new Date().getTime() - d.now.getTime()) / d.sampleRate / 60000) > d.visibleValues ? .5 : 0
Behavior on opacity { NumberAnimation {} }
}
DateTimeAxis {
id: dateTimeAxis
min: d.startTime
max: d.endTime
format: {
switch (selectionTabs.currentValue.sampleRate) {
case NewLogsModel.SampleRate1Min:
case NewLogsModel.SampleRate15Mins:
return "hh:mm"
case NewLogsModel.SampleRate1Hour:
case NewLogsModel.SampleRate3Hours:
case NewLogsModel.SampleRate1Day:
return "dd.MM."
}
}
tickCount: {
switch (selectionTabs.currentValue.sampleRate) {
case NewLogsModel.SampleRate1Min:
case NewLogsModel.SampleRate15Mins:
return root.width > 500 ? 13 : 7
case NewLogsModel.SampleRate1Hour:
return 7
case NewLogsModel.SampleRate3Hours:
case NewLogsModel.SampleRate1Day:
return root.width > 500 ? 12 : 6
}
}
labelsFont: Style.extraSmallFont
gridVisible: false
minorGridVisible: false
lineVisible: false
shadesVisible: false
labelsColor: Style.foregroundColor
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
anchors.leftMargin: chartView.plotArea.x
anchors.topMargin: 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
propagateComposedEvents: true
property int startMouseX: 0
property bool dragging: false
property bool tooltipping: false
property var startDatetime: null
Timer {
interval: 300
running: mouseArea.pressed
onTriggered: {
if (!mouseArea.dragging) {
mouseArea.tooltipping = true
}
}
}
onReleased: {
if (mouseArea.dragging) {
logsModel.fetchLogs()
mouseArea.dragging = false;
}
mouseArea.tooltipping = false;
}
onPressed: {
startMouseX = mouseX
startDatetime = d.now
}
onDoubleClicked: {
if (selectionTabs.currentIndex == 0) {
return;
}
var idx = Math.ceil(mouseArea.mouseX * d.visibleValues / mouseArea.width)
var timestamp = new Date(d.startTime.getTime() + (idx * d.sampleRate * 60000))
selectionTabs.currentIndex--
d.now = new Date(Math.min(new Date().getTime(), timestamp.getTime() + (d.visibleValues / 2) * d.sampleRate * 60000))
powerBalanceLogs.fetchLogs()
}
onMouseXChanged: {
if (!pressed || mouseArea.tooltipping) {
return;
}
if (Math.abs(startMouseX - mouseX) < 10) {
return;
}
dragging = true
var dragDelta = startMouseX - mouseX
var totalTime = d.endTime.getTime() - d.startTime.getTime()
// dragDelta : timeDelta = width : totalTime
var timeDelta = dragDelta * totalTime / mouseArea.width
// print("dragging", dragDelta, totalTime, mouseArea.width)
d.now = new Date(Math.min(new Date(), new Date(startDatetime.getTime() + timeDelta)))
}
onWheel: {
startDatetime = d.now
var totalTime = d.endTime.getTime() - d.startTime.getTime()
// pixelDelta : timeDelta = width : totalTime
var timeDelta = wheel.pixelDelta.x * totalTime / mouseArea.width
// print("wheeling", wheel.pixelDelta.x, totalTime, mouseArea.width)
d.now = new Date(Math.min(new Date(), new Date(startDatetime.getTime() - timeDelta)))
wheelStopTimer.restart()
}
Timer {
id: wheelStopTimer
interval: 300
repeat: false
onTriggered: d.refreshAll()
}
Rectangle {
height: parent.height
width: 1
color: Style.foregroundColor
x: Math.min(mouseArea.width, Math.max(0, mouseArea.mouseX))
visible: tooltipRepeater.tooltipsVisible
}
Timer {
id: updateTimer
interval: 0
onTriggered: tooltipRepeater.update()
}
Repeater {
id: tooltipRepeater
model: root.statesModel.length
property var timestamp: new Date(((d.endTime.getTime() - d.startTime.getTime()) * mouseArea.mouseX / mouseArea.width) + d.startTime.getTime())
property int tooltipWidth: 130
property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins
property int xOnLeft: Math.min(mouseArea.width, mouseArea.mouseX) - Style.smallMargins - tooltipWidth
property int tooltipX: xOnLeft < 0 ? xOnRight : xOnLeft
property bool tooltipsVisible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging
onTimestampChanged: {
updateTimer.start();
}
function update() {
var ordered = []
insert(tooltipRepeater, ordered);
for (var i = ordered.length - 1; i >= 0; i--) {
var item = ordered[i]
var newY = item.realY
if (i < ordered.length-1) {
var previous = ordered[i+1]
newY = Math.min(newY, previous.fixedY - item.height/* - Style.extraSmallMargins*/)
}
ordered[i].fixedY = newY
}
}
function insert(repeater, array) {
for (var i = 0; i < repeater.count; i++) {
var item = repeater.itemAt(i);
var insertIdx = 0;
while (array.length > insertIdx && item.realY > array[insertIdx].realY) {
insertIdx++
}
array.splice(insertIdx, 0, item)
}
}
delegate: NymeaToolTip {
id: tooltip
width: tooltipRepeater.tooltipWidth
height: layout.implicitHeight + Style.smallMargins * 2
visible: tooltipRepeater.tooltipsVisible && entry != null
x: tooltipRepeater.tooltipX
backgroundItem: chartView
backgroundRect: Qt.rect(mouseArea.x + x, mouseArea.y + y, width, height)
property Item chartDelegate: modelsRepeater.count > 0 ? modelsRepeater.itemAt(index) : null
property Thing thing: chartDelegate ? chartDelegate.thing : null
property NewLogEntry entry: chartDelegate ? chartDelegate.logsModel.find(tooltipRepeater.timestamp) : null
property string valueName: chartDelegate ? chartDelegate.stateType.name : ""
// property alias iconSource: icon.name
property ValueAxis axis: chartDelegate ? chartDelegate.axis : null
property int unit: chartDelegate ? chartDelegate.stateType.unit : Types.UnitNone
readonly property var value: entry ? entry.values[valueName] : null
readonly property int realY: entry ? Math.min(Math.max(mouseArea.height - (value * mouseArea.height / axis.max) - height / 2 /*- Style.margins*/, 0), mouseArea.height - height) : 0
property int fixedY: 0
y: fixedY // Animated
RowLayout {
id: layout
anchors.fill: parent
anchors.margins: Style.smallMargins
ColorIcon {
id: icon
size: Style.smallIconSize
color: chartDelegate ? chartDelegate.color : "red"
visible: name != ""
}
Rectangle {
id: rect
width: Style.extraSmallFont.pixelSize
height: width
color: chartDelegate ? chartDelegate.color : "red"
visible: !icon.visible
}
Label {
text: root.statesModel[index].hasOwnProperty("tooltipFunction")
? root.statesModel[index].tooltipFunction(tooltip.value)
: "%1: %2%3".arg(thing.name).arg(entry ? round(Types.toUiValue(tooltip.value, unit)) : "-").arg(Types.toUiUnit(tooltip.unit))
Layout.fillWidth: true
font: Style.extraSmallFont
elide: Text.ElideMiddle
function round(value) {
return Math.round(value * 100) / 100
}
}
}
}
}
}
}
}
}