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 } } } } } } } } }