From 1d21e88e67b545a3bfacdf986f80e8b893b93e8c Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 22 Aug 2022 13:59:04 +0200 Subject: [PATCH] Make the energy charts scrollable --- libnymea-app/energy/energylogs.cpp | 233 ++++- libnymea-app/energy/energylogs.h | 34 +- libnymea-app/energy/powerbalancelogs.cpp | 101 +-- libnymea-app/energy/powerbalancelogs.h | 19 +- libnymea-app/energy/thingpowerlogs.cpp | 322 ++++--- libnymea-app/energy/thingpowerlogs.h | 93 +- libnymea-app/libnymea-app-core.h | 1 + nymea-app/resources.qrc | 1 + nymea-app/ui/components/ActivityIndicator.qml | 52 ++ nymea-app/ui/components/SelectionTabs.qml | 3 + nymea-app/ui/mainviews/EnergyView.qml | 8 - .../ui/mainviews/energy/ConsumerStats.qml | 812 ++++++++--------- .../ui/mainviews/energy/ConsumersHistory.qml | 791 +++++++++++------ .../ui/mainviews/energy/PowerBalanceStats.qml | 809 +++++++++-------- .../energy/PowerConsumptionBalanceHistory.qml | 817 +++++++++++------- .../energy/PowerProductionBalanceHistory.qml | 808 ++++++++++------- nymea-app/ui/mainviews/energy/StatsBase.qml | 68 +- 17 files changed, 2989 insertions(+), 1983 deletions(-) create mode 100644 nymea-app/ui/components/ActivityIndicator.qml diff --git a/libnymea-app/energy/energylogs.cpp b/libnymea-app/energy/energylogs.cpp index e56e40df..55d49879 100644 --- a/libnymea-app/energy/energylogs.cpp +++ b/libnymea-app/energy/energylogs.cpp @@ -1,6 +1,7 @@ #include "energylogs.h" #include +#include #include "logging.h" NYMEA_LOGGING_CATEGORY(dcEnergyLogs, "EnergyLogs") @@ -59,9 +60,9 @@ void EnergyLogs::setEngine(Engine *engine) if (m_engine->jsonRpcClient()->experiences().value("Energy").toString() >= "1.0") { m_engine->jsonRpcClient()->registerNotificationHandler(this, "Energy", "notificationReceivedInternal"); - if (m_ready && !m_loadingInhibited) { - fetchLogs(); - } +// if (m_ready && !m_loadingInhibited) { +// fetchLogs(); +// } } } } @@ -76,8 +77,7 @@ void EnergyLogs::setSampleRate(SampleRate sampleRate) if (m_sampleRate != sampleRate) { m_sampleRate = sampleRate; emit sampleRateChanged(); - - fetchLogs(); + clear(); } } @@ -136,9 +136,9 @@ void EnergyLogs::setLoadingInhibited(bool loadingInhibited) m_loadingInhibited = loadingInhibited; emit loadingInhibitedChanged(); - if (!m_loadingInhibited) { - fetchLogs(); - } +// if (!m_loadingInhibited) { +// fetchLogs(); +// } } } @@ -150,7 +150,7 @@ void EnergyLogs::classBegin() void EnergyLogs::componentComplete() { m_ready = true; - fetchLogs(); +// fetchLogs(); } int EnergyLogs::rowCount(const QModelIndex &parent) const @@ -166,6 +166,16 @@ QVariant EnergyLogs::data(const QModelIndex &index, int role) const return QVariant(); } +double EnergyLogs::minValue() const +{ + return m_minValue; +} + +double EnergyLogs::maxValue() const +{ + return m_maxValue; +} + EnergyLogEntry *EnergyLogs::get(int index) const { if (index < 0 || index >= m_list.count()) { @@ -174,27 +184,54 @@ EnergyLogEntry *EnergyLogs::get(int index) const return m_list.at(index); } -void EnergyLogs::appendEntry(EnergyLogEntry *entry) +EnergyLogEntry *EnergyLogs::find(const QDateTime ×tamp) +{ + if (m_list.isEmpty()) { + return nullptr; + } + QDateTime first = m_list.first()->timestamp(); + if (timestamp < first || timestamp > m_list.last()->timestamp()) { + return nullptr; + } + int index = qRound(1.0 * first.secsTo(timestamp) / (m_sampleRate * 60)); + if (index < 0 || index >= m_list.count()) { + return nullptr; + } + return m_list.at(index); +} + +void EnergyLogs::appendEntry(EnergyLogEntry *entry, double minValue, double maxValue) { entry->setParent(this); - beginInsertRows(QModelIndex(), m_list.count(), m_list.count()); + int index = m_list.count(); + beginInsertRows(QModelIndex(), index, index); m_list.append(entry); endInsertRows(); emit countChanged(); - emit entryAdded(entry); - emit entriesAdded({entry}); + emit entryAdded(index, entry); + emit entriesAdded(index, {entry}); + if (minValue < m_minValue) { + m_minValue = minValue; + emit minValueChanged(); + } + if (maxValue > m_maxValue) { + m_maxValue = maxValue; + emit maxValueChanged(); + } } void EnergyLogs::appendEntries(const QList &entries) { - beginInsertRows(QModelIndex(), m_list.count(), m_list.count() + entries.count()); - foreach (EnergyLogEntry* entry, entries) { + int index = m_list.count(); + beginInsertRows(QModelIndex(), index, index + entries.count()); + for (int i = 0; i < entries.count(); i++) { + EnergyLogEntry* entry = entries.at(i); entry->setParent(this); m_list.append(entry); - emit entryAdded(entry); + emit entryAdded(index + i, entry); } endInsertRows(); - emit entriesAdded(entries); + emit entriesAdded(index, entries); emit countChanged(); } @@ -206,18 +243,92 @@ QVariantMap EnergyLogs::fetchParams() const void EnergyLogs::getLogsResponse(int commandId, const QVariantMap ¶ms) { Q_UNUSED(commandId) - if (!m_list.isEmpty()) { - beginResetModel(); - qDeleteAll(m_list); - m_list.clear(); - endResetModel(); + + double minValue = 0, maxValue = 0; +// qCDebug(dcEnergyLogs()) << "Logs response:" << qUtf8Printable(QJsonDocument::fromVariant(params).toJson()); + QList entries = unpackEntries(params, &minValue, &maxValue); + + if (!entries.isEmpty()) { + if (m_list.isEmpty()) { + qCDebug(dcEnergyLogs()) << "Energy logs received"; + beginInsertRows(QModelIndex(), 0, entries.count()); + m_list.append(entries); + endInsertRows(); + emit entriesAdded(0, entries); + m_minValue = minValue; + emit minValueChanged(); + m_maxValue = maxValue; + emit maxValueChanged(); + + } else if (entries.first()->timestamp() < m_list.first()->timestamp()) { + + if (entries.last()->timestamp().addSecs(m_sampleRate * 60) == m_list.first()->timestamp()) { + beginInsertRows(QModelIndex(), 0, entries.count()); + m_list = entries + m_list; + endInsertRows(); + emit entriesAdded(0, entries); + if (minValue < m_minValue) { + m_minValue = minValue; + emit minValueChanged(); + } + if (maxValue > m_maxValue) { + m_maxValue = maxValue; + emit maxValueChanged(); + } + } else { + // End of fetched entries does not line up with start of existing entries. Discarding existing entries + qCDebug(dcEnergyLogs()) << "End of fetched entries does not line up with start of existing entries. Discarding existing entries" << entries.last()->timestamp().addSecs(m_sampleRate * 60).toString() << " - " << m_list.first()->timestamp().toString(); + clear(); + beginInsertRows(QModelIndex(), 0, entries.count()); + m_list.append(entries); + endInsertRows(); + emit entriesAdded(0, entries); + m_minValue = minValue; + emit minValueChanged(); + m_maxValue = maxValue; + emit maxValueChanged(); + } + + } else if (entries.first()->timestamp().addSecs(-m_sampleRate * 60) == m_list.last()->timestamp()) { + int index = m_list.count(); + beginInsertRows(QModelIndex(), m_list.count(), m_list.count() + entries.count()); + m_list.append(entries); + endInsertRows(); + emit entriesAdded(index, entries); + if (minValue < m_minValue) { + m_minValue = minValue; + emit minValueChanged(); + } + if (maxValue > m_maxValue) { + m_maxValue = maxValue; + emit maxValueChanged(); + } + } else { + // Start of fetched entries does not line up with end of existing entries. Discarding existing entries + clear(); + beginInsertRows(QModelIndex(), 0, entries.count()); + m_list.append(entries); + endInsertRows(); + emit entriesAdded(0, entries); + m_minValue = minValue; + emit minValueChanged(); + m_maxValue = maxValue; + emit maxValueChanged(); + } + + } else { + qCDebug(dcEnergyLogs()) << "Received empty log entries set."; } - qCDebug(dcEnergyLogs()) << "Energy logs received"; -// qCDebug(dcEnergyLogs()) << "Energy logs:" << params; - logEntriesReceived(params); m_fetchingData = false; - emit fetchingDataChanged(); + + if (m_fetchAgain) { + qCDebug(dcEnergyLogs()) << "Fetching again..."; + m_fetchAgain = false; + fetchLogs(); + } else { + emit fetchingDataChanged(); + } } void EnergyLogs::notificationReceivedInternal(const QVariantMap &data) @@ -234,32 +345,72 @@ void EnergyLogs::notificationReceivedInternal(const QVariantMap &data) notificationReceived(data); } +void EnergyLogs::clear() +{ + int count = m_list.count(); + beginResetModel(); + qDeleteAll(m_list); + m_list.clear(); + endResetModel(); + emit countChanged(); + emit entriesRemoved(0, count); + m_minValue = 0; + emit minValueChanged(); + m_maxValue = 0; + emit maxValueChanged(); +} + void EnergyLogs::fetchLogs() { if (m_loadingInhibited || !m_ready || !m_engine || m_engine->jsonRpcClient()->experiences().value("Energy").toString() < "1.0") { return; } - if (!m_list.isEmpty()) { - beginResetModel(); - qDeleteAll(m_list); - m_list.clear(); - endResetModel(); + if (m_fetchingData) { + qCDebug(dcEnergyLogs()) << "Already busy.. queing up call"; + m_fetchAgain = true; + return; + } + + QVariantMap params = fetchParams(); + QMetaEnum metaEnum = QMetaEnum::fromType(); + params.insert("sampleRate", metaEnum.valueToKey(m_sampleRate)); + + if (!m_startTime.isNull() && !m_endTime.isNull()) { + QDateTime startTime; + QDateTime endTime; + + QDateTime oldestExisting = m_list.count() > 0 ? m_list.first()->timestamp() : QDateTime(); + QDateTime newestExisting = m_list.count() > 0 ? m_list.last()->timestamp() : QDateTime(); + qCDebug(dcEnergyLogs()) << "request timeframe: " << m_startTime.toString() << " - " << m_endTime.toString(); + qCDebug(dcEnergyLogs()) << "existing timeframe:" << oldestExisting.toString() << "- " << newestExisting.toString(); + + if (oldestExisting.isNull() || newestExisting.isNull()) { + startTime = m_startTime; + endTime = m_endTime; + } else { + + if (m_startTime < oldestExisting) { + startTime = m_startTime; + endTime = qMin(m_endTime, oldestExisting.addSecs(-m_sampleRate * 60)); + } else if (newestExisting < m_endTime) { + startTime = qMax(m_startTime, newestExisting.addSecs(m_sampleRate * 60)); + endTime = m_endTime; + } else { + // Nothing to do... + return; + } + } + + params.insert("from", startTime.toSecsSinceEpoch()); + params.insert("to", endTime.toSecsSinceEpoch()); + qCDebug(dcEnergyLogs()) << "Fetching from" << startTime.toString() << "to" << endTime.toString() << "with sample rate" << m_sampleRate; } m_fetchingData = true; fetchingDataChanged(); - QVariantMap params = fetchParams(); - QMetaEnum metaEnum = QMetaEnum::fromType(); - params.insert("sampleRate", metaEnum.valueToKey(m_sampleRate)); - if (!m_startTime.isNull()) { - params.insert("from", m_startTime.toSecsSinceEpoch()); - } - if (!m_endTime.isNull()) { - params.insert("to", m_endTime.toSecsSinceEpoch()); - } - qCDebug(dcEnergyLogs()) << this << "Fetching energy logs" << params; + qCDebug(dcEnergyLogs()) << "Fetching" << m_startTime << m_endTime; m_engine->jsonRpcClient()->sendCommand("Energy.Get" + logsName(), params, this, "getLogsResponse"); } diff --git a/libnymea-app/energy/energylogs.h b/libnymea-app/energy/energylogs.h index 2662668e..f330c679 100644 --- a/libnymea-app/energy/energylogs.h +++ b/libnymea-app/energy/energylogs.h @@ -34,6 +34,10 @@ class EnergyLogs : public QAbstractListModel, public QQmlParserStatus Q_PROPERTY(bool live READ live WRITE setLive NOTIFY liveChanged) Q_PROPERTY(bool fetchingData READ fetchingData NOTIFY fetchingDataChanged) Q_PROPERTY(bool loadingInhibited READ loadingInhibited WRITE setLoadingInhibited NOTIFY loadingInhibitedChanged) + Q_PROPERTY(double minValue READ minValue NOTIFY minValueChanged) + Q_PROPERTY(double maxValue READ maxValue NOTIFY maxValueChanged) + + friend class ThingPowerLogs; public: enum SampleRate { @@ -77,13 +81,19 @@ public: int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role) const override; + double minValue() const; + double maxValue() const; + Q_INVOKABLE EnergyLogEntry* get(int index) const; + Q_INVOKABLE EnergyLogEntry* find(const QDateTime ×tamp); + +public slots: + void clear(); + void fetchLogs(); signals: void engineChanged(); void sampleRateChanged(); - void fetchPowerBalanceChanged(); - void thingIdsChanged(); void startTimeChanged(); void endTimeChanged(); void liveChanged(); @@ -91,34 +101,40 @@ signals: void loadingInhibitedChanged(); void countChanged(); - void entryAdded(EnergyLogEntry *entry); - void entriesAdded(const QList entries); + void entryAdded(int index, EnergyLogEntry *entry); + void entriesAdded(int index, const QList entries); + void entriesRemoved(int index, int count); + + void minValueChanged(); + void maxValueChanged(); protected: virtual QString logsName() const = 0; virtual QVariantMap fetchParams() const; - virtual void logEntriesReceived(const QVariantMap ¶ms) = 0; + virtual QList unpackEntries(const QVariantMap ¶ms, double *minValue, double *maxValue) = 0; virtual void notificationReceived(const QVariantMap &data) = 0; - void appendEntry(EnergyLogEntry *entry); + void appendEntry(EnergyLogEntry *entry, double minValue, double maxValue); void appendEntries(const QList &entries); -private slots: +protected slots: void getLogsResponse(int commandId, const QVariantMap ¶ms); void notificationReceivedInternal(const QVariantMap &data); - void fetchLogs(); private: Engine *m_engine = nullptr; SampleRate m_sampleRate = SampleRate15Mins; bool m_fetchPowerBalance = true; - QList m_thingIds; QDateTime m_startTime; QDateTime m_endTime; bool m_live = true; bool m_fetchingData = false; bool m_loadingInhibited = false; bool m_ready = false; + bool m_fetchAgain = false; + + double m_minValue = 0; + double m_maxValue = 0; QList m_list; }; diff --git a/libnymea-app/energy/powerbalancelogs.cpp b/libnymea-app/energy/powerbalancelogs.cpp index 1ff3b68b..f422b7ea 100644 --- a/libnymea-app/energy/powerbalancelogs.cpp +++ b/libnymea-app/energy/powerbalancelogs.cpp @@ -66,100 +66,14 @@ PowerBalanceLogs::PowerBalanceLogs(QObject *parent) : EnergyLogs(parent) } -double PowerBalanceLogs::minValue() const -{ - return m_minValue; -} - -double PowerBalanceLogs::maxValue() const -{ - return m_maxValue; -} - QString PowerBalanceLogs::logsName() const { return "PowerBalanceLogs"; } -void PowerBalanceLogs::addEntry(PowerBalanceLogEntry *entry) -{ - if (entry->consumption() < m_minValue) { - m_minValue = entry->consumption(); - emit minValueChanged(); - } - if (entry->consumption() > m_maxValue) { - m_maxValue = entry->consumption(); - emit maxValueChanged(); - } - - if (entry->production() < m_minValue) { - m_minValue = entry->production(); - emit minValueChanged(); - } - if (entry->production() > m_maxValue) { - m_maxValue = entry->production(); - emit maxValueChanged(); - } - if (entry->acquisition() < m_minValue) { - m_minValue = entry->acquisition(); - emit minValueChanged(); - } - if (entry->acquisition() > m_maxValue) { - m_maxValue = entry->acquisition(); - emit maxValueChanged(); - } - if (entry->storage() < m_minValue) { - m_minValue = entry->storage(); - emit minValueChanged(); - } - if (entry->storage() > m_maxValue) { - m_maxValue = entry->storage(); - emit maxValueChanged(); - } - - appendEntry(entry); -} - -EnergyLogEntry *PowerBalanceLogs::find(const QDateTime ×tamp) const -{ - qWarning() << "Finding log entry for timestamp:" << timestamp; - int oldest = 0; - int newest = rowCount() - 1; - EnergyLogEntry *entry = nullptr; - int step = 0; - while (oldest <= newest && step < rowCount()) { - EnergyLogEntry *oldestEntry = get(oldest); - EnergyLogEntry *newestEntry = get(newest); - int middle = (newest - oldest) / 2 + oldest; - EnergyLogEntry *middleEntry = get(middle); - qWarning() << "Oldest:" << oldestEntry->timestamp().toString() << "Middle:" << middleEntry->timestamp().toString() << "Newest:" << newestEntry->timestamp().toString() << ":" << (newest - oldest); - if (timestamp <= oldestEntry->timestamp()) { - return oldestEntry; - } - if (timestamp >= newestEntry->timestamp()) { - return newestEntry; - } - - if (timestamp == middleEntry->timestamp()) { - return middleEntry; - } - - if (timestamp < middleEntry->timestamp()) { - newest = middle; - } else { - oldest = middle; - } - - if ((newest - oldest) <= 1) { - return newestEntry; - } - step++; - } - return entry; -} - -void PowerBalanceLogs::logEntriesReceived(const QVariantMap ¶ms) +QList PowerBalanceLogs::unpackEntries(const QVariantMap ¶ms, double *minValue, double *maxValue) { + QList ret; foreach (const QVariant &variant, params.value("powerBalanceLogEntries").toList()) { QVariantMap map = variant.toMap(); QDateTime timestamp = QDateTime::fromSecsSinceEpoch(map.value("timestamp").toLongLong()); @@ -172,10 +86,13 @@ void PowerBalanceLogs::logEntriesReceived(const QVariantMap ¶ms) double totalAcquisition = map.value("totalAcquisition").toDouble(); double totalReturn = map.value("totalReturn").toDouble(); PowerBalanceLogEntry *entry = new PowerBalanceLogEntry(timestamp, consumption, production, acquisition, storage, totalConsumption, totalProduction, totalAcquisition, totalReturn, this); -// qCritical() << "Adding entry:" << entry->timestamp() << entry->totalConsumption(); - addEntry(entry); + *minValue = qMin(qMin(qMin(qMin(*minValue, consumption), production), acquisition), storage); + *maxValue = qMax(qMax(qMax(qMax(*maxValue, consumption), production), acquisition), storage); + + ret.append(entry); } + return ret; } void PowerBalanceLogs::notificationReceived(const QVariantMap &data) @@ -202,7 +119,9 @@ void PowerBalanceLogs::notificationReceived(const QVariantMap &data) double totalAcquisition = map.value("totalAcquisition").toDouble(); double totalReturn = map.value("totalReturn").toDouble(); PowerBalanceLogEntry *entry = new PowerBalanceLogEntry(timestamp, consumption, production, acquisition, storage, totalConsumption, totalProduction, totalAcquisition, totalReturn, this); - addEntry(entry); + double minValue = qMin(qMin(qMin(consumption, production), acquisition), storage); + double maxValue = qMax(qMax(qMax(consumption, production), acquisition), storage); + appendEntry(entry, minValue, maxValue); } } diff --git a/libnymea-app/energy/powerbalancelogs.h b/libnymea-app/energy/powerbalancelogs.h index 82826bf4..562ee739 100644 --- a/libnymea-app/energy/powerbalancelogs.h +++ b/libnymea-app/energy/powerbalancelogs.h @@ -46,30 +46,13 @@ private: class PowerBalanceLogs : public EnergyLogs { Q_OBJECT - Q_PROPERTY(double minValue READ minValue NOTIFY minValueChanged) - Q_PROPERTY(double maxValue READ maxValue NOTIFY maxValueChanged) public: explicit PowerBalanceLogs(QObject *parent = nullptr); - double minValue() const; - double maxValue() const; - - Q_INVOKABLE EnergyLogEntry* find(const QDateTime ×tamp) const; - -signals: - void minValueChanged(); - void maxValueChanged(); - protected: QString logsName() const override; - void logEntriesReceived(const QVariantMap ¶ms) override; + QList unpackEntries(const QVariantMap ¶ms, double *minValue, double *maxValue) override; void notificationReceived(const QVariantMap &data) override; - -private: - void addEntry(PowerBalanceLogEntry *entry); - - double m_minValue = 0; - double m_maxValue = 0; }; diff --git a/libnymea-app/energy/thingpowerlogs.cpp b/libnymea-app/energy/thingpowerlogs.cpp index 77f987ef..61161895 100644 --- a/libnymea-app/energy/thingpowerlogs.cpp +++ b/libnymea-app/energy/thingpowerlogs.cpp @@ -2,6 +2,9 @@ #include +#include +Q_DECLARE_LOGGING_CATEGORY(dcEnergyLogs) + ThingPowerLogEntry::ThingPowerLogEntry(QObject *parent): EnergyLogEntry(parent) { @@ -39,67 +42,46 @@ double ThingPowerLogEntry::totalProduction() const ThingPowerLogs::ThingPowerLogs(QObject *parent) : EnergyLogs(parent) { - m_cacheTimer.setInterval(2000); - connect(&m_cacheTimer, &QTimer::timeout, this, [=](){ - if (m_cachedEntries.count() > 0) { - addEntries(m_cachedEntries); - m_cachedEntries.clear(); +} + +QUuid ThingPowerLogs::thingId() const +{ + return m_thingId; +} + +void ThingPowerLogs::setThingId(const QUuid &thingId) +{ + if (m_thingId != thingId) { + m_thingId = thingId; + emit thingIdChanged(); + if (m_loader) { + m_loader->addThingId(thingId); } - }); + } } -QList ThingPowerLogs::thingIds() const +ThingPowerLogsLoader *ThingPowerLogs::loader() const { - return m_thingIds; + return m_loader; } -void ThingPowerLogs::setThingIds(const QList &thingIds) +void ThingPowerLogs::setLoader(ThingPowerLogsLoader *loader) { - if (m_thingIds != thingIds) { - m_thingIds = thingIds; - emit thingIdsChanged(); + if (m_loader != loader) { + m_loader = loader; + emit loaderChanged(); + + loader->addThingId(m_thingId); + connect(loader, &ThingPowerLogsLoader::fetched, this, [=](int commandId, const QVariantMap ¶ms){ + qCDebug(dcEnergyLogs()) << "Loader fetched data."; + getLogsResponse(commandId, params); + }); } } -double ThingPowerLogs::minValue() const +ThingPowerLogEntry *ThingPowerLogs::liveEntry() { - return m_minValue; -} - -double ThingPowerLogs::maxValue() const -{ - return m_maxValue; -} - -ThingPowerLogEntry *ThingPowerLogs::find(const QUuid &thingId, const QDateTime ×tamp) -{ - // TODO: Can we do a binary search even if they key we're looking for is not unique (but still sorted)? - // For now, 365 * consumers items is the max we'll have here which seems on the edge for doing a stupid linear search... -// qWarning() << "Finding item for" << thingId.toString() << timestamp.toString(); - for (int i = rowCount() - 1; i >= 0; i--) { - ThingPowerLogEntry *entry = static_cast(get(i)); - if (entry->thingId() != thingId) { - continue; - } -// qWarning() << "comparing" << entry->timestamp().toString(); - if (timestamp == entry->timestamp()) { - return entry; - } - if (timestamp > entry->timestamp()) { - return nullptr; // Giving up, entry is not here - } - } - return nullptr; -} - -ThingPowerLogEntry *ThingPowerLogs::liveEntry(const QUuid &thingId) -{ - return m_liveEntries.value(thingId); -} - -void ThingPowerLogs::addEntry(ThingPowerLogEntry *entry) -{ - appendEntry(entry); + return m_liveEntry; } void ThingPowerLogs::addEntries(const QList &entries) @@ -128,32 +110,33 @@ QString ThingPowerLogs::logsName() const QVariantMap ThingPowerLogs::fetchParams() const { - QVariantList thingIdsStrings; - foreach (const QUuid &id, m_thingIds) { - thingIdsStrings.append(id.toString()); - } QVariantMap ret; - ret.insert("thingIds", thingIdsStrings); + ret.insert("thingIds", QVariantList{m_thingId}); ret.insert("includeCurrent", true); return ret; } -void ThingPowerLogs::logEntriesReceived(const QVariantMap ¶ms) +QList ThingPowerLogs::unpackEntries(const QVariantMap ¶ms, double *minValue, double *maxValue) { foreach (const QVariant &variant, params.value("currentEntries").toList()) { QVariantMap map = variant.toMap(); - ThingPowerLogEntry *entry = unpack(map); - if (m_liveEntries.contains(entry->thingId())) { - m_liveEntries[entry->thingId()]->deleteLater(); + if (map.value("thingId").toUuid() != m_thingId) { + continue; } - m_liveEntries[entry->thingId()] = entry; - emit liveEntryChanged(entry); + if (m_liveEntry) { + m_liveEntry->deleteLater(); + } + m_liveEntry = unpack(map); + emit liveEntryChanged(m_liveEntry); + break; } - // Grouping them so when the UI gets entriesAdded, the whole set for this timstamp will be available at once - QList groupForTimestamp; + QList ret; foreach (const QVariant &variant, params.value("thingPowerLogEntries").toList()) { QVariantMap map = variant.toMap(); + if (map.value("thingId").toUuid() != m_thingId) { + continue; + } QDateTime timestamp = QDateTime::fromSecsSinceEpoch(map.value("timestamp").toLongLong()); QUuid thingId = map.value("thingId").toUuid(); double currentPower = map.value("currentPower").toDouble(); @@ -162,21 +145,13 @@ void ThingPowerLogs::logEntriesReceived(const QVariantMap ¶ms) ThingPowerLogEntry *entry = new ThingPowerLogEntry(timestamp, thingId, currentPower, totalConsumption, totalProduction, this); // qWarning() << "Adding entry:" << entry->thingId() << entry->timestamp().toString() << entry->totalConsumption(); - if (groupForTimestamp.isEmpty()) { - groupForTimestamp.append(entry); - } else if (groupForTimestamp.first()->timestamp() == timestamp) { - groupForTimestamp.append(entry); - } else { - // Finalize previous group and start a new one - addEntries(groupForTimestamp); - groupForTimestamp.clear(); - groupForTimestamp.append(entry); - } + *minValue = qMin(*minValue, currentPower); + *maxValue = qMax(*maxValue, currentPower); + + ret.append(entry); } - if (!groupForTimestamp.isEmpty()) { - addEntries(groupForTimestamp); - } + return ret; } void ThingPowerLogs::notificationReceived(const QVariantMap &data) @@ -189,52 +164,193 @@ void ThingPowerLogs::notificationReceived(const QVariantMap &data) QVariantMap entryMap = params.value("thingPowerLogEntry").toMap(); QUuid thingId = entryMap.value("thingId").toUuid(); - if (!m_thingIds.isEmpty() && !m_thingIds.contains(thingId)) { + if (m_thingId != thingId) { // Not watching this thing... return; } + if (sampleRate != this->sampleRate()) { + return; + } + // We'll use 1 Min samples in any case for the live value if (sampleRate == EnergyLogs::SampleRate1Min) { ThingPowerLogEntry *liveEntry = unpack(params.value("thingPowerLogEntry").toMap()); - if (m_liveEntries.contains(thingId)) { - m_liveEntries.value(thingId)->deleteLater(); + if (m_liveEntry) { + m_liveEntry->deleteLater(); } - m_liveEntries[thingId] = liveEntry; + m_liveEntry = liveEntry; emit liveEntryChanged(liveEntry); } - // And append the sample rate we're interested in - if (sampleRate != this->sampleRate()) { - return; - } - if (notification == "Energy.ThingPowerLogEntryAdded") { QVariantMap map = params.value("thingPowerLogEntry").toMap(); QDateTime timestamp = QDateTime::fromSecsSinceEpoch(map.value("timestamp").toLongLong()); QUuid thingId = map.value("thingId").toUuid(); - if (!m_thingIds.isEmpty() && !m_thingIds.contains(thingId)) { - return; - } double currentPower = map.value("currentPower").toDouble(); double totalConsumption = map.value("totalConsumption").toDouble(); double totalProduction = map.value("totalProduction").toDouble(); ThingPowerLogEntry *entry = new ThingPowerLogEntry(timestamp, thingId, currentPower, totalConsumption, totalProduction, this); - - // In order to be easier on resources, we'll batch notifications by grouping them by timestamp - // While the timestamp is the same, just cache the changes. Once the timestamp changes, we'll finalize the - // batch and actually append them. - // Also if we're not getting any more notification for a while and still have cached entries, we'll process the batch - if (m_cachedEntries.isEmpty()) { - m_cachedEntries.append(entry); - } else if (entry->timestamp() == m_cachedEntries.first()->timestamp()) { - m_cachedEntries.append(entry); - } else { - addEntries(m_cachedEntries); - m_cachedEntries.clear(); - m_cachedEntries.append(entry); - } - m_cacheTimer.start(); + appendEntry(entry, currentPower, currentPower); } } + +ThingPowerLogsLoader::ThingPowerLogsLoader(QObject *parent): + QObject(parent) +{ + +} + +Engine *ThingPowerLogsLoader::engine() const +{ + return m_engine; +} + +void ThingPowerLogsLoader::setEngine(Engine *engine) +{ + if (m_engine != engine) { + m_engine = engine; + emit engineChanged(); + + if (!m_engine) { + return; + } + + connect(engine, &Engine::destroyed, this, [=](){ + if (engine == m_engine) { + m_engine = nullptr; + emit engineChanged(); + } + }); + } +} + +EnergyLogs::SampleRate ThingPowerLogsLoader::sampleRate() const +{ + return m_sampleRate; +} + +void ThingPowerLogsLoader::setSampleRate(EnergyLogs::SampleRate sampleRate) +{ + if (m_sampleRate != sampleRate) { + m_sampleRate = sampleRate; + emit sampleRateChanged(); + + m_lastStartTime = QDateTime(); + m_lastEndTime = QDateTime(); + } +} + +QDateTime ThingPowerLogsLoader::startTime() const +{ + return m_startTime; +} + +void ThingPowerLogsLoader::setStartTime(const QDateTime &startTime) +{ + if (m_startTime != startTime) { + m_startTime = startTime; + emit startTimeChanged(); + } +} + +QDateTime ThingPowerLogsLoader::endTime() const +{ + return m_endTime; +} + +void ThingPowerLogsLoader::setEndTime(const QDateTime &endTime) +{ + if (m_endTime != endTime) { + m_endTime = endTime; + emit endTimeChanged(); + } +} + +bool ThingPowerLogsLoader::fetchingData() const +{ + return m_fetchingData; +} + +void ThingPowerLogsLoader::addThingId(const QUuid &thingId) +{ + if (!m_thingIds.contains(thingId)) { + m_thingIds.append(thingId); + } +} + +void ThingPowerLogsLoader::fetchLogs() +{ + if (!m_engine || m_engine->jsonRpcClient()->experiences().value("Energy").toString() < "1.0") { + return; + } + + if (m_fetchingData) { + qCDebug(dcEnergyLogs()) << "Already busy.. queing up call"; + m_fetchAgain = true; + return; + } + + QVariantMap params; + QVariantList thingIds; + foreach (const QUuid &thingId, m_thingIds) { + thingIds.append(thingId); + } + params.insert("thingIds", thingIds); + params.insert("includeCurrent", true); + + QMetaEnum metaEnum = QMetaEnum::fromType(); + params.insert("sampleRate", metaEnum.valueToKey(m_sampleRate)); + + if (!m_startTime.isNull() && !m_endTime.isNull()) { + QDateTime startTime; + QDateTime endTime; + if (m_lastStartTime.isNull() || m_lastEndTime.isNull()) { + startTime = m_startTime; + endTime = m_endTime; + m_lastStartTime = m_startTime; + m_lastEndTime = m_endTime; + } else { + if (m_startTime < m_lastStartTime) { + startTime = m_startTime; + endTime = m_lastStartTime; + m_lastStartTime = m_startTime; + } else if (m_lastEndTime < m_endTime) { + startTime = m_lastEndTime; + endTime = m_endTime; + m_lastEndTime = m_endTime; + } else { + // Nothing to do... + m_fetchingData = false; + emit fetchingDataChanged(); + return; + } + + } + + params.insert("from", startTime.toSecsSinceEpoch()); + params.insert("to", endTime.addSecs(-1).toSecsSinceEpoch()); + qCDebug(dcEnergyLogs()) << "Fetching from" << startTime.toString() << "to" << endTime.toString() << "with sample rate" << m_sampleRate; + } + + m_fetchingData = true; + fetchingDataChanged(); + + m_engine->jsonRpcClient()->sendCommand("Energy.GetThingPowerLogs", params, this, "getLogsResponse"); +} + +void ThingPowerLogsLoader::getLogsResponse(int commandId, const QVariantMap ¶ms) +{ + qCDebug(dcEnergyLogs()) << "Logs loader response!"; + emit fetched(commandId, params); + + m_fetchingData = false; + + if (m_fetchAgain) { + m_fetchAgain = false; + fetchLogs(); + } else { + emit fetchingDataChanged(); + } +} diff --git a/libnymea-app/energy/thingpowerlogs.h b/libnymea-app/energy/thingpowerlogs.h index acd768d9..0ffc4d55 100644 --- a/libnymea-app/energy/thingpowerlogs.h +++ b/libnymea-app/energy/thingpowerlogs.h @@ -29,53 +29,98 @@ private: double m_totalProduction = 0; }; +class ThingPowerLogsLoader; + class ThingPowerLogs : public EnergyLogs { Q_OBJECT - Q_PROPERTY(QList thingIds READ thingIds WRITE setThingIds NOTIFY thingIdsChanged) - Q_PROPERTY(double minValue READ minValue NOTIFY minValueChanged) - Q_PROPERTY(double maxValue READ maxValue NOTIFY maxValueChanged) + Q_PROPERTY(QUuid thingId READ thingId WRITE setThingId NOTIFY thingIdChanged) + Q_PROPERTY(ThingPowerLogsLoader* loader READ loader WRITE setLoader NOTIFY loaderChanged) public: explicit ThingPowerLogs(QObject *parent = nullptr); - QList thingIds() const; - void setThingIds(const QList &thingIds); + QUuid thingId() const; + void setThingId(const QUuid &thingId); - double minValue() const; - double maxValue() const; + ThingPowerLogsLoader *loader() const; + void setLoader(ThingPowerLogsLoader *loader); - Q_INVOKABLE ThingPowerLogEntry *find(const QUuid &thingId, const QDateTime ×tamp); - - Q_INVOKABLE ThingPowerLogEntry *liveEntry(const QUuid &thingId); + Q_INVOKABLE ThingPowerLogEntry *liveEntry(); signals: - void thingIdsChanged(); - - void minValueChanged(); - void maxValueChanged(); - - void liveEntryChanged(ThingPowerLogEntry *entry); + void thingIdChanged(); + void loaderChanged(); + void liveEntryChanged(ThingPowerLogEntry *liveEntry); protected: QString logsName() const override; QVariantMap fetchParams() const override; - void logEntriesReceived(const QVariantMap ¶ms) override; + QList unpackEntries(const QVariantMap ¶ms, double *minValue, double *maxValue) override; void notificationReceived(const QVariantMap &data) override; private: - void addEntry(ThingPowerLogEntry *entry); void addEntries(const QList &entries); ThingPowerLogEntry *unpack(const QVariantMap &map); + QUuid m_thingId; + ThingPowerLogEntry* m_liveEntry = nullptr; + ThingPowerLogsLoader* m_loader = nullptr; +}; + +class ThingPowerLogsLoader: public QObject +{ + Q_OBJECT + Q_PROPERTY(Engine *engine READ engine WRITE setEngine NOTIFY engineChanged) + Q_PROPERTY(EnergyLogs::SampleRate sampleRate READ sampleRate WRITE setSampleRate NOTIFY sampleRateChanged) + Q_PROPERTY(QDateTime startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) + Q_PROPERTY(QDateTime endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) + Q_PROPERTY(bool fetchingData READ fetchingData NOTIFY fetchingDataChanged) + +public: + ThingPowerLogsLoader(QObject *parent = nullptr); + + Engine *engine() const; + void setEngine(Engine *engine); + + EnergyLogs::SampleRate sampleRate() const; + void setSampleRate(EnergyLogs::SampleRate sampleRate); + + QDateTime startTime() const; + void setStartTime(const QDateTime &startTime); + + QDateTime endTime() const; + void setEndTime(const QDateTime &endTime); + + bool fetchingData() const; + + void addThingId(const QUuid &thingId); + +public slots: + void fetchLogs(); + +signals: + void engineChanged(); + void sampleRateChanged(); + void startTimeChanged(); + void endTimeChanged(); + void fetchingDataChanged(); + void fetched(int commandId, const QVariantMap ¶ms); + +private slots: + void getLogsResponse(int commandId, const QVariantMap ¶ms); + +private: + Engine *m_engine = nullptr; + EnergyLogs::SampleRate m_sampleRate = EnergyLogs::SampleRate15Mins; + QDateTime m_startTime; + QDateTime m_endTime; QList m_thingIds; - double m_minValue = 0; - double m_maxValue = 0; + bool m_fetchingData = false; + bool m_fetchAgain = false; + QDateTime m_lastStartTime; + QDateTime m_lastEndTime; - QList m_cachedEntries; - QTimer m_cacheTimer; - - QHash m_liveEntries; }; #endif // THINGPOWERLOGS_H diff --git a/libnymea-app/libnymea-app-core.h b/libnymea-app/libnymea-app-core.h index ff5d51ba..fa7b0265 100644 --- a/libnymea-app/libnymea-app-core.h +++ b/libnymea-app/libnymea-app-core.h @@ -383,6 +383,7 @@ void registerQmlTypes() { qmlRegisterType(uri, 1, 0, "PowerBalanceLogEntry"); qmlRegisterType(uri, 1, 0, "ThingPowerLogEntry"); qmlRegisterType(uri, 1, 0, "ThingPowerLogs"); + qmlRegisterType(uri, 1, 0, "ThingPowerLogsLoader"); qmlRegisterType(uri, 1, 0, "SortFilterProxyModel"); } diff --git a/nymea-app/resources.qrc b/nymea-app/resources.qrc index 6a04eac4..5ebc381f 100644 --- a/nymea-app/resources.qrc +++ b/nymea-app/resources.qrc @@ -280,5 +280,6 @@ ui/system/zwave/ZWaveAddNetworkPage.qml ui/system/zwave/ZWaveNetworkPage.qml ui/system/zwave/ZWaveNetworkSettingsPage.qml + ui/components/ActivityIndicator.qml diff --git a/nymea-app/ui/components/ActivityIndicator.qml b/nymea-app/ui/components/ActivityIndicator.qml new file mode 100644 index 00000000..4b946d23 --- /dev/null +++ b/nymea-app/ui/components/ActivityIndicator.qml @@ -0,0 +1,52 @@ +import QtQuick 2.0 +import Nymea 1.0 + +Item { + id: root + property color color: Style.iconColor + implicitWidth: Style.iconSize + implicitHeight: Style.iconSize + + property int dotSize: width / 6 + + property bool running: true + + Grid { + id: grid + columns: 3 + anchors.fill: parent + spacing: (width - columns * root.dotSize) / (columns - 1) + + Repeater { + id: dotRepeater + model: Math.pow(grid.columns, 2) + delegate: Rectangle { + id: dot + width: root.dotSize + height: width + color: root.color + property int duration: 400 + property int row: Math.floor(index / grid.columns) + property int pause: row * 200 + + SequentialAnimation { + running: root.running && root.visible + loops: Animation.Infinite + PauseAnimation { duration: dot.pause } + NumberAnimation { + target: dot + property: "opacity" + from: 0.2; to: 1; + duration: dot.duration + } + NumberAnimation { + target: dot + property: "opacity" + from: 1; to: 0.2; + duration: dot.duration + } + } + } + } + } +} diff --git a/nymea-app/ui/components/SelectionTabs.qml b/nymea-app/ui/components/SelectionTabs.qml index 03e19c4d..1cab2129 100644 --- a/nymea-app/ui/components/SelectionTabs.qml +++ b/nymea-app/ui/components/SelectionTabs.qml @@ -13,6 +13,8 @@ Rectangle { property alias model: repeater.model readonly property var currentValue: model.hasOwnProperty("get") ? model.get(currentIndex) : model[currentIndex] + signal tabSelected(int index) + Rectangle { x: repeater.count > 0 ? repeater.itemAt(root.currentIndex).x + 1 : 0 @@ -45,6 +47,7 @@ Rectangle { onClicked: { print("current index:", index) root.currentIndex = index + root.tabSelected(index) } } } diff --git a/nymea-app/ui/mainviews/EnergyView.qml b/nymea-app/ui/mainviews/EnergyView.qml index 2cde5d65..e3555292 100644 --- a/nymea-app/ui/mainviews/EnergyView.qml +++ b/nymea-app/ui/mainviews/EnergyView.qml @@ -150,14 +150,6 @@ MainViewBase { animationsEnabled: Qt.application.active && root.isCurrentItem } -// ConsumersBarChart { -// Layout.fillWidth: true -// Layout.preferredHeight: width -// energyManager: energyManager -// visible: consumers.count > 0 -// colors: root.thingColors -// consumers: consumers -// } ConsumersHistory { Layout.fillWidth: true Layout.preferredHeight: width diff --git a/nymea-app/ui/mainviews/energy/ConsumerStats.qml b/nymea-app/ui/mainviews/energy/ConsumerStats.qml index 79a8b6e5..86e30745 100644 --- a/nymea-app/ui/mainviews/energy/ConsumerStats.qml +++ b/nymea-app/ui/mainviews/energy/ConsumerStats.qml @@ -13,229 +13,156 @@ StatsBase { property ThingsProxy consumers: null - Connections { - target: consumers - onCountChanged: root.update() - } + QtObject { + id: d - Connections { - target: engine.thingManager - onFetchingDataChanged: root.update() - } - Connections { - target: engine.tagsManager - onBusyChanged: root.update() - } + property var config: root.configs[selectionTabs.currentValue.config] - function update() { - if (engine.thingManager.fetchingData || engine.tagsManager.busy || selectionTabs.currentValue === undefined) { - return + property int startOffset: 0 + + 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 } - powerLogs.loadingInhibited = true - var thingIds = [] - for (var i = 0; i < consumers.count; i++) { - thingIds.push(consumers.get(i).id) + onLoadingChanged: { + if (!loading) { + refresh() + } } - powerLogs.thingIds = thingIds - var config = root.configs[selectionTabs.currentValue.config] -// print("config:", config.startTime(), config.sampleList(), config.sampleListNames()) - - powerLogs.sampleRate = config.sampleRate - powerLogs.startTime = new Date(config.startTime().getTime() - config.sampleRate * 60000) - - chartView.reset(); - - powerLogs.loadingInhibited = false + function refresh() { + for (var i = 0; i < consumersRepeater.count; i++) { + consumersRepeater.itemAt(i).refresh() + } + } } - ThingPowerLogs { - id: powerLogs + ThingPowerLogsLoader { + id: logsLoader engine: _engine - loadingInhibited: true - - property var sampleList: null + 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) { - var config = root.configs[selectionTabs.currentValue.config] + print("Logs fetched") + d.fetchPending = false + } + } + } - chartView.reset() + Repeater { + id: consumersRepeater + model: root.consumers + onCountChanged: { + if (count == root.consumers.count) { + logsLoader.fetchLogs(); + } + } - // First grouping log entries by timestamp - var groupedEntries = [] - var groupedEntry = {} - for (var i = powerLogs.count - 1; i >= 0; i--) { - var entry = powerLogs.get(i); -// print("grouping entry:", entry.timestamp, "current group entry", groupedEntry.timestamp, groupedEntry.hasOwnProperty("timestamp")) - if (!groupedEntry.hasOwnProperty("timestamp")) { - groupedEntry.timestamp = entry.timestamp; -// print("Starting new groupentry", groupedEntry.timestamp, entry.timestamp) - } - if (groupedEntry.timestamp.getTime() !== entry.timestamp.getTime()) { - if (groupedEntries.length > config.count) { - break; - } -// print("finalizing grouped entry", groupedEntry.timestamp) - groupedEntries.unshift(groupedEntry); - groupedEntry = { - timestamp: entry.timestamp - } -// print("Starting new groupentry", groupedEntry.timestamp, entry.timestamp) - } - groupedEntry[entry.thingId] = entry.totalConsumption - } - if (groupedEntry.hasOwnProperty("timestamp") && groupedEntries.length <= config.count) { -// print("finalizing grouped entry", groupedEntry.timestamp) - groupedEntries.unshift(groupedEntry) + 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; + } - var labels = [] - var entries = [] - - var newestLogTimestamp = powerLogs.count > 0 ? powerLogs.get(powerLogs.count - 1).timestamp : new Date(); - - for (var i = 0; i < config.count; i++) { - var groupedEntry = groupedEntries[groupedEntries.length - i - 1] -// print("have grouped entry:", groupedEntry ? groupedEntry.timestamp : "null") - - // if it's the first, let's add a generated entry which shows the total from the newest log to the current live value - if (i == 0) { - var liveEntry = {} - for (var j = 0; j < consumers.count; j++) { - var consumer = consumers.get(j) - var liveLogEntry = powerLogs.liveEntry(consumer.id) -// print("Got consumer:", consumer.id, consumer.name, liveLogEntry ? liveLogEntry.timestamp : "-") - var value = liveLogEntry ? liveLogEntry.totalConsumption : 0; - if (groupedEntry) { - value -= groupedEntry.hasOwnProperty(consumer.id) ? groupedEntry[consumer.id] : 0 - } - liveEntry[consumer.id] = value - valueAxis.adjustMax(value) + 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 (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) -// print("Adding live entry", JSON.stringify(liveEntry)) - entries.unshift(liveEntry) - } - - // Add the actual entry - var graphEntry = {} - var labelTime = new Date(); - - if (groupedEntry) { - var previousGroupedEntry = groupedEntries[groupedEntries.length - i - 2] - for (var j = 0; j < consumers.count; j++) { - var consumer = consumers.get(j) - var value = groupedEntry.hasOwnProperty(consumer.id) ? groupedEntry[consumer.id] : 0 - if (previousGroupedEntry) { - var previousValue = previousGroupedEntry.hasOwnProperty(consumer.id) ? previousGroupedEntry[consumer.id] : 0 - value -= previousValue - } - graphEntry[consumer.id] = value - valueAxis.adjustMax(value) + } 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 } - labelTime = groupedEntry.timestamp + barSet.replace(i, consumption) + valueAxis.adjustMax(consumption) } else { - for (var j = 0; j < consumers.count; j++) { - var consumer = consumers.get(j) - graphEntry[consumer.id] = 0 - } - labelTime = calculateSampleStart(newestLogTimestamp, config.sampleRate, i) - } - -// print("Adding entry:", labelTime, config.toLabel(labelTime), JSON.stringify(graphEntry)) - entries.unshift(graphEntry) - labels.unshift(labelTime) - - // Given we've added 2 entries for the first run but only one label, we'll add the missing label - // at the end. This will shift the labels by one entries but that's ok because the logs timestamp - // is when the sample was created, but for the user it's better to show the the consumption values - // *during* that sample, not *before* the sample - if (i == config.count - 1) { - labelTime = new Date(labelTime.getTime() - config.sampleRate * 60000) -// print("Adding oldest entry label", labelTime, config.sampleRate, config.toLabel(labelTime)) - labels.unshift(labelTime) - } - - } - -// print("assigning categories:", labels) - categoryAxis.timestamps = labels - - for (var i = 0; i < entries.length; i++) { - var entry = entries[i] -// print("Adding entry", JSON.stringify(entry)) - for (var j = 0; j < consumers.count; j++) { - var consumer = consumers.get(j) - barSeries.thingBarSetMap[consumer.id].append(entry[consumer.id]) + barSet.replace(i, 0) } } } - } - onEntriesAdded: { - if (fetchingData) { - return + 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() + } } - chartView.animationOptions = ChartView.NoAnimation + Component.onCompleted: { + var values = [] + for (var i = 0; i < d.config.count; i++) { + values.push(0) + } - for (var i = 0; i < entries.length; i++) { - var entry = entries[i] - var thing = engine.thingManager.things.getThing(entry.thingId) -// print("Adding new sample. thing:", thing.name); -// print("Timestamp:", entry.timestamp, entry.totalConsumption) - // update current last - var barSet = barSeries.thingBarSetMap[thing.id] - var lastTimestamp = categoryAxis.timestamps[categoryAxis.count - 1] - var previous = powerLogs.find(entry.thingId, lastTimestamp) - var previousValue = previous ? previous.totalConsumption : 0 -// print("previousValue:", previousValue, "newValue:", entry.totalConsumption, "diff", entry.totalConsumption - previousValue) - barSet.replace(barSet.count - 1, entry.totalConsumption - previousValue) - - // remove the oldest - barSet.remove(0, 1) - - // and add a new one (always 0 for a start) - barSet.append(0) + barSet = barSeries.append(consumerDelegate.thing.name, values) + barSet.color = NymeaUtils.generateColor(Style.generationBaseColor, index) + barSet.borderColor = barSet.color + barSet.borderWith = 0 } - - var labels = categoryAxis.timestamps - labels.splice(0, 1) - labels.push(entries[0].timestamp) - categoryAxis.timestamps = labels - - chartView.animationOptions = NymeaUtils.chartsAnimationOptions - } - - onLiveEntryChanged: { - if (powerLogs.fetchingData) { - return - } - -// print("live entry changed", entry.thingId, entry.timestamp) - var previous = powerLogs.find(entry.thingId, new Date(categoryAxis.timestamps[categoryAxis.timestamps.length - 1])) - var previousValue = previous ? previous.totalConsumption : 0 - var barSet = barSeries.thingBarSetMap[entry.thingId] - - if (!barSet) { - return - } - - barSet.replace(barSet.count - 1, entry.totalConsumption - previousValue) } } ColumnLayout { anchors.fill: parent + spacing: 0 Label { Layout.fillWidth: true Layout.margins: Style.smallMargins horizontalAlignment: Text.AlignHCenter text: qsTr("Consumers totals") - } SelectionTabs { @@ -243,246 +170,347 @@ StatsBase { Layout.fillWidth: true Layout.leftMargin: Style.smallMargins Layout.rightMargin: Style.smallMargins - currentIndex: 0 + currentIndex: 1 model: ListModel { - Component.onCompleted: { - append({modelData: qsTr("Hours"), config: "hours" }) - append({modelData: qsTr("Days"), config: "days" }) - append({modelData: qsTr("Weeks"), config: "weeks" }) - append({modelData: qsTr("Months"), config: "months" }) - append({modelData: qsTr("Years"), config: "years" }) -// append({modelData: qsTr("Minutes"), config: "minutes" }) - - selectionTabs.currentIndex = 1 - } + 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" } } - onCurrentValueChanged: { - root.update() + onTabSelected: { + d.startOffset = 0 + logsLoader.fetchLogs(); } } - - ChartView { - id: chartView + Item { Layout.fillWidth: true Layout.fillHeight: true - // margins.left: 0 - margins.right: 0 - margins.bottom: 0 - margins.top: 0 + 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 {} } + } - backgroundColor: "transparent" - legend.alignment: Qt.AlignBottom - legend.font: Style.extraSmallFont - legend.labelColor: Style.foregroundColor + ChartView { + id: chartView + anchors.fill: parent - function reset() { - chartView.animationOptions = ChartView.NoAnimation - barSeries.clear(); - valueAxis.max = 0 - var map = {} - for (var j = 0; j < consumers.count; j++) { - var consumer = consumers.get(j) - var barSet = barSeries.append(consumer.name, []) -// barSet.color = root.colors[j % root.colors.length] - barSet.color = NymeaUtils.generateColor(Style.generationBaseColor, j) - barSet.borderColor = barSet.color - barSet.borderWith = 0 - map[consumer.id] = barSet + backgroundColor: "transparent" + // margins.left: 0 + margins.right: 0 + margins.bottom: 0 + margins.top: 0 + + 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()) + } + } + + print("oldestEntry", new Date(oldestEntry), haveItems) + 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 = [] + print("Updating categories from", d.config.startTime()) + for (var i = 0; i < d.config.count; i++) { + var timestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + i); + print("*** adding", timestamp, 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) + } + } + } } - barSeries.thingBarSetMap = map - chartView.animationOptions = NymeaUtils.chartsAnimationOptions } 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 - } + 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 } } } } - 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 < timestamps.length; i++) { - ret.push(root.configs[selectionTabs.currentValue.config].toLabel(timestamps[i])) - } - return ret - } + 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 - property var timestamps: [] - } - axisY: ValueAxis { - id: valueAxis - min: 0 - gridLineColor: Style.tileOverlayColor - labelsVisible: false - labelsColor: Style.foregroundColor - labelsFont: Style.extraSmallFont - lineVisible: false - titleVisible: false - shadesVisible: false + hoverEnabled: true + preventStealing: tooltipping || dragging - function adjustMax(newValue) { - if (max < newValue) { - max = Math.ceil(newValue) + 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 } } } - property var thingBarSetMap: ({}) - } - } - } - - 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 - - Timer { - interval: 300 - running: mouseArea.pressed - onTriggered: mouseArea.preventStealing = true - } - onReleased: mouseArea.preventStealing = false - - - 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: Math.max(0, Math.min(categoryAxis.count -1, Math.floor(mouseArea.mouseX * categoryAxis.count / mouseArea.width))) - visible: mouseArea.containsMouse || mouseArea.preventStealing - - 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 < consumers.count; i++) { - var consumer = consumers.get(i) - max = barSeries.thingBarSetMap.hasOwnProperty(consumer.id) ? Math.max(max, barSeries.thingBarSetMap[consumer.id].at(idx)) : 0 - } - 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: toolTip.idx >= 0 && categoryAxis.timestamps.length > toolTip.idx ? root.configs[selectionTabs.currentValue.config].toLongLabel(categoryAxis.timestamps[toolTip.idx]) : "" - font: Style.smallFont - } - - Repeater { - model: ListModel { - id: toolTipModel - property var entries: { - var unsorted = [] - for (var i = 0; i < consumers.count; i++) { - var consumer = consumers.get(i) - var entry = { - name: consumer.name, - value: barSeries.thingBarSetMap[consumer.id].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]) - } - } + onReleased: { + if (mouseArea.dragging) { + logsLoader.fetchLogs(); + d.refresh() + mouseArea.dragging = false; } + mouseArea.tooltipping = false; + } - delegate: RowLayout { - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width -// color: root.colors[model.indexInModel % root.colors.length] - color: NymeaUtils.generateColor(Style.generationBaseColor, model.indexInModel) + 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: Math.ceil(mouseArea.mouseX * d.config.count / mouseArea.width) - 1 + 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: "%1: %2 kWh".arg(model.name).arg(model.value) - font: Style.extraSmallFont + text: d.config.toLongLabel(toolTip.timestamp) + font: Style.smallFont + } + + Repeater { + model: ListModel { + id: toolTipModel + property var entries: { + var unsorted = [] + for (var i = 0; i < consumers.count; i++) { + var consumer = consumers.get(i) + var entry = { + name: consumer.name, + 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 { + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + // color: root.colors[model.indexInModel % root.colors.length] + color: NymeaUtils.generateColor(Style.generationBaseColor, model.indexInModel) + } + Label { + text: "%1: %2 kWh".arg(model.name).arg(model.value) + font: Style.extraSmallFont + } + } } } } } } + } + } diff --git a/nymea-app/ui/mainviews/energy/ConsumersHistory.qml b/nymea-app/ui/mainviews/energy/ConsumersHistory.qml index a09bdfc1..44589f50 100644 --- a/nymea-app/ui/mainviews/energy/ConsumersHistory.qml +++ b/nymea-app/ui/mainviews/energy/ConsumersHistory.qml @@ -11,142 +11,62 @@ Item { property var colors: null property ThingsProxy consumers: null - Connections { - target: consumers - onCountChanged: d.updateConsumers() - } - Connections { - target: engine.tagsManager - onBusyChanged: d.updateConsumers() - } - - ThingPowerLogs { - id: thingPowerLogs + PowerBalanceLogs { + id: powerBalanceLogs engine: _engine - startTime: dateTimeAxis.min - sampleRate: EnergyLogs.SampleRate15Mins - thingIds: [] - loadingInhibited: thingIds.length === 0 - - onModelReset: { - for (var i = 0; i < consumers.count; i++) { - var consumer = consumers.get(i); - var series = d.thingsSeriesMap[consumer.id]; - series.upperSeries.clear() - } - } + startTime: new Date(d.startTime.getTime() - d.range * 60000) + endTime: new Date(d.endTime.getTime() + d.range * 60000) + sampleRate: d.sampleRate + Component.onCompleted: fetchLogs() onEntriesAdded: { - var thingValues = ({}) - var timestamp = entries[0].timestamp + print("entries added", index, entries.length) for (var i = 0; i < entries.length; i++) { var entry = entries[i] - var thing = engine.thingManager.things.getThing(entries[i].thingId) - thingValues[entry.thingId] = entry.currentPower - } +// print("got entry", entry.timestamp) - // Add them in the order of the chart (same as proxy), summing it up - var totalValue = 0; - for (var i = 0; i < consumers.count; i++) { - var consumer = consumers.get(i); - var value = thingValues.hasOwnProperty(consumer.id) ? thingValues[consumer.id] : 0 - totalValue += thingValues.hasOwnProperty(consumer.id) ? thingValues[consumer.id] : 0; - var series = d.thingsSeriesMap[consumer.id]; - series.upperSeries.append(timestamp, totalValue) + zeroSeries.ensureValue(entry.timestamp) + valueAxis.adjustMax(entry.consumption) + consumptionSeries.insertEntry(index + i, entry) + if (entry.timestamp > d.now && new Date().getTime() - d.now.getTime() < 120000) { + d.now = entry.timestamp + } } - thingPowerLogs.maxValue = Math.max(thingPowerLogs.maxValue, totalValue) } - property double maxValue: 0 + onEntriesRemoved: { + consumptionUpperSeries.removePoints(index, count) + zeroSeries.shrink() + } } - property PowerBalanceLogs powerBalanceLogs: PowerBalanceLogs { + ThingPowerLogsLoader { + id: logsLoader engine: _engine - startTime: dateTimeAxis.min - sampleRate: EnergyLogs.SampleRate15Mins - - onEntryAdded: { - consumptionSeries.addEntry(entry) - - if (dateTimeAxis.now < entry.timestamp) { - dateTimeAxis.now = entry.timestamp - zeroSeries.update(entry.timestamp) - } - } - } - - Timer { - interval: 60000 - repeat: true - onTriggered: { - var now = new Date() - if (dateTimeAxis.now < now) { - dateTimeAxis.now = now - zeroSeries.update(now) - } - } - } - - Connections { - target: engine.thingManager - onFetchingDataChanged: d.updateConsumers() - onThingAdded: { - if (thing.thingClass.interfaces.indexOf("smartmeterconsumer") >= 0) { - d.updateConsumers(); - } - } - } - - - Component.onCompleted: { - for (var i = 0; i < powerBalanceLogs.count; i++) { - var entry = powerBalanceLogs.get(i); - consumptionSeries.addEntry(entry) - } - - d.updateConsumers(); + startTime: new Date(d.startTime.getTime() - d.range * 60000) + endTime: new Date(d.endTime.getTime() + d.range * 60000) + sampleRate: d.sampleRate } QtObject { id: d - property var thingsSeriesMap: ({}) - function updateConsumers() { - if (engine.thingManager.fetchingData || engine.tagsManager.busy) { - return; - } - thingPowerLogs.loadingInhibited = true; + property date now: new Date() - for (var thingId in d.thingsSeriesMap) { - chartView.removeSeries(d.thingsSeriesMap[thingId]) - } - d.thingsSeriesMap = ({}) + readonly property int range: selectionTabs.currentValue.range + readonly property int sampleRate: selectionTabs.currentValue.sampleRate + readonly property int visibleValues: range / sampleRate - var consumerThingIds = [] - for (var i = 0; i < consumers.count; i++) { - var thing = consumers.get(i); + readonly property var startTime: { + var date = new Date(now); + date.setTime(date.getTime() - range * 60000 + 2000); + return date; + } - var baseSeries = zeroSeries; - if (i > 0) { - baseSeries = d.thingsSeriesMap[consumerThingIds[i-1]].upperSeries - // print("base for:", thing.name, "is", engine.thingManager.things.getThing(consumerThingIds[i-1]).name) - } - - var series = chartView.createSeries(ChartView.SeriesTypeArea, thing.name, dateTimeAxis, valueAxis) - series.lowerSeries = baseSeries - series.upperSeries = lineSeriesComponent.createObject(series) -// series.color = root.colors[i % root.colors.length] - series.color = NymeaUtils.generateColor(Style.generationBaseColor, i) - series.borderWidth = 0; - series.borderColor = series.color - - var map = d.thingsSeriesMap - map[thing.id] = series - d.thingsSeriesMap = map - consumerThingIds.push(thing.id) - } - thingPowerLogs.thingIds = consumerThingIds; - thingPowerLogs.loadingInhibited = false; + readonly property var endTime: { + var date = new Date(now); + date.setTime(date.getTime() + 2000) + return date; } } @@ -155,202 +75,497 @@ Item { LineSeries { } } - ChartView { - id: chartView + ColumnLayout { anchors.fill: parent + spacing: 0 - backgroundColor: "transparent" - margins.left: 0 - margins.right: 0 - margins.bottom: 0 - margins.top: 0 - - - title: qsTr("Consumers history") - titleColor: Style.foregroundColor - - legend.alignment: Qt.AlignBottom - legend.labelColor: Style.foregroundColor - legend.font: Style.extraSmallFont - - ValueAxis { - id: valueAxis - min: 0 - max: Math.ceil(Math.max(powerBalanceLogs.maxValue, thingPowerLogs.maxValue) / 1000) * 1000 - labelFormat: "" - gridLineColor: Style.tileOverlayColor - labelsVisible: false - lineVisible: false - titleVisible: false - shadesVisible: false - // visible: false + Label { + Layout.fillWidth: true + Layout.margins: Style.smallMargins + horizontalAlignment: Text.AlignHCenter + text: qsTr("Consumers history") + } + SelectionTabs { + id: selectionTabs + Layout.fillWidth: true + Layout.leftMargin: Style.smallMargins + Layout.rightMargin: Style.smallMargins + currentIndex: 1 + model: ListModel { + ListElement { + modelData: qsTr("Hours") + sampleRate: EnergyLogs.SampleRate1Min + range: 180 // 3 Hours: 3 * 60 + } + ListElement { + modelData: qsTr("Days") + sampleRate: EnergyLogs.SampleRate15Mins + range: 1440 // 1 Day: 24 * 60 + } + ListElement { + modelData: qsTr("Weeks") + sampleRate: EnergyLogs.SampleRate1Hour + range: 10080 // 7 Days: 7 * 24 * 60 + } + ListElement { + modelData: qsTr("Months") + sampleRate: EnergyLogs.SampleRate3Hours + range: 43200 // 30 Days: 30 * 24 * 60 + } + } + onTabSelected: { + d.now = new Date() + powerBalanceLogs.fetchLogs() + logsLoader.fetchLogs(); + } } 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))) / 1000).toFixed(2) + "kW" - verticalAlignment: Text.AlignTop - font: Style.extraSmallFont - } - } - } + Layout.fillWidth: true + Layout.fillHeight: true - DateTimeAxis { - id: dateTimeAxis - property date now: new Date() - min: { - var date = new Date(now); - date.setTime(date.getTime() - (1000 * 60 * 60 * 24) + 2000); - return date; - } - max: { - var date = new Date(now); - date.setTime(date.getTime() + 2000) - return date; - } - format: "hh:mm" - labelsFont: Style.extraSmallFont - gridVisible: false - minorGridVisible: false - lineVisible: false - shadesVisible: false - labelsColor: Style.foregroundColor - } - - AreaSeries { - id: consumptionSeries - axisX: dateTimeAxis - axisY: valueAxis - color: Style.gray - borderWidth: 0 - borderColor: color - name: qsTr("Unknown") - - lowerSeries: LineSeries { - id: zeroSeries - XYPoint { x: dateTimeAxis.min.getTime(); y: 0 } - XYPoint { x: dateTimeAxis.max.getTime(); y: 0 } - function update(timestamp) { - append(timestamp, 0); - removePoints(1,1); - } - } - upperSeries: LineSeries { - id: consumptionUpperSeries + Label { + x: chartView.x + chartView.plotArea.x + (chartView.plotArea.width - width) / 2 + y: chartView.y + chartView.plotArea.y + Style.smallMargins + text: d.startTime.toLocaleDateString(Qt.locale(), Locale.LongFormat) + font: Style.smallFont + opacity: ((new Date().getTime() - d.now.getTime()) / d.sampleRate / 60000) > d.visibleValues ? .5 : 0 + Behavior on opacity { NumberAnimation {} } } - function addEntry(entry) { - consumptionUpperSeries.append(entry.timestamp.getTime(), entry.consumption) - } - } + ChartView { + id: chartView + anchors.fill: parent - } + backgroundColor: "transparent" + margins.left: 0 + margins.right: 0 + margins.bottom: 0 + margins.top: 0 - 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 + legend.alignment: Qt.AlignBottom + legend.font: Style.extraSmallFont + legend.labelColor: Style.foregroundColor - hoverEnabled: true - - Timer { - interval: 300 - running: mouseArea.pressed - onTriggered: mouseArea.preventStealing = true - } - onReleased: mouseArea.preventStealing = false - - Rectangle { - height: parent.height - width: 1 - color: Style.foregroundColor - x: Math.min(mouseArea.width - 1, Math.max(0, mouseArea.mouseX)) - visible: mouseArea.containsMouse || mouseArea.preventStealing - } - - NymeaToolTip { - id: toolTip - visible: mouseArea.containsMouse || mouseArea.preventStealing - - backgroundItem: chartView - backgroundRect: Qt.rect(mouseArea.x + toolTip.x, mouseArea.y + toolTip.y, toolTip.width, toolTip.height) - - property int idx: consumptionUpperSeries.count - Math.floor(mouseArea.mouseX * consumptionUpperSeries.count / mouseArea.width) - property int seriesIndex: Math.min(consumptionUpperSeries.count - 1, Math.max(0, consumptionUpperSeries.count - idx)) - - property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins - property int xOnLeft: Math.min(mouseArea.width, mouseArea.mouseX) - Style.smallMargins - width - x: xOnRight + width < mouseArea.width ? xOnRight : xOnLeft - property double maxValue: consumptionUpperSeries.at(seriesIndex).y - y: Math.min(Math.max(mouseArea.height - (maxValue * mouseArea.height / valueAxis.max) - height - Style.margins, 0), mouseArea.height - height) - - width: tooltipLayout.implicitWidth + Style.smallMargins * 2 - height: tooltipLayout.implicitHeight + Style.smallMargins * 2 - - property date timestamp: new Date(consumptionUpperSeries.at(seriesIndex).x) - - ColumnLayout { - id: tooltipLayout - anchors { - left: parent.left - top: parent.top - margins: Style.smallMargins + ActivityIndicator { + x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2 + y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8) + visible: powerBalanceLogs.fetchingData || logsLoader.fetchingData + opacity: .5 } Label { - text: toolTip.timestamp.toLocaleString(Qt.locale(), Locale.ShortFormat) + 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") + visible: !powerBalanceLogs.fetchingData && !logsLoader.fetchingData && (powerBalanceLogs.count == 0 || powerBalanceLogs.get(0).timestamp > d.now) font: Style.smallFont + opacity: .5 } - RowLayout { - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: consumptionSeries.color - } - Label { - property double rawValue: consumptionUpperSeries.at(toolTip.seriesIndex).y - property double displayValue: rawValue >= 1000 ? rawValue / 1000 : rawValue - property string unit: rawValue >= 1000 ? "kW" : "W" - text: "%1: %2 %3".arg(qsTr("Total")).arg(displayValue.toFixed(2)).arg(unit) - font: Style.extraSmallFont + + + ValueAxis { + id: valueAxis + min: 0 + max: 1 + labelFormat: "" + gridLineColor: Style.tileOverlayColor + labelsVisible: false + lineVisible: false + titleVisible: false + shadesVisible: false + // visible: false + + function adjustMax(value) { + max = Math.max(max, Math.ceil(value / 100) * 100) } } - Repeater { - model: consumers - delegate: RowLayout { - id: consumerToolTipDelegate - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width -// color: index >= 0 ? root.colors[index % root.colors.length] : "white" - color: index >= 0 ? NymeaUtils.generateColor(Style.generationBaseColor, index) : "white" - } - - Label { - property ThingPowerLogEntry entry: thingPowerLogs.find(model.id, toolTip.timestamp) - property double rawValue: entry ? entry.currentPower : 0 - property double displayValue: rawValue >= 1000 ? rawValue / 1000 : rawValue - property string unit: rawValue >= 1000 ? "kW" : "W" - text: "%1: %2 %3".arg(model.name).arg(displayValue.toFixed(2)).arg(unit) + 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))) / 1000).toFixed(2) + "kW" + verticalAlignment: Text.AlignTop font: Style.extraSmallFont } } } + + DateTimeAxis { + id: dateTimeAxis + min: d.startTime + max: d.endTime + format: { + switch (selectionTabs.currentValue.sampleRate) { + case EnergyLogs.SampleRate1Min: + case EnergyLogs.SampleRate15Mins: + return "hh:mm" + case EnergyLogs.SampleRate1Hour: + case EnergyLogs.SampleRate3Hours: + case EnergyLogs.SampleRate1Day: + return "dd.MM." + } + } + tickCount: { + switch (selectionTabs.currentValue.sampleRate) { + case EnergyLogs.SampleRate1Min: + case EnergyLogs.SampleRate15Mins: + return root.width > 500 ? 13 : 7 + case EnergyLogs.SampleRate1Hour: + return 7 + case EnergyLogs.SampleRate3Hours: + case EnergyLogs.SampleRate1Day: + return root.width > 500 ? 12 : 6 + } + } + labelsFont: Style.extraSmallFont + gridVisible: false + minorGridVisible: false + lineVisible: false + shadesVisible: false + labelsColor: Style.foregroundColor + } + + AreaSeries { + id: consumptionSeries + axisX: dateTimeAxis + axisY: valueAxis + color: Style.gray + borderWidth: 0 + borderColor: color + name: qsTr("Unknown") +// visible: false + opacity: .2 + + lowerSeries: LineSeries { + id: zeroSeries + XYPoint { x: dateTimeAxis.min.getTime(); y: 0 } + XYPoint { x: dateTimeAxis.max.getTime(); y: 0 } + function ensureValue(timestamp) { + if (count == 0) { + append(timestamp, 0) + } else if (count == 1) { + if (timestamp.getTime() < at(0).x) { + insert(0, timestamp, 0) + } else { + append(timestamp, 0) + } + } else { + if (timestamp.getTime() < at(0).x) { + remove(0) + insert(0, timestamp, 0) + } else if (timestamp.getTime() > at(1).x) { + remove(1) + append(timestamp, 0) + } + } + } + function shrink() { + clear(); + if (powerBalanceLogs.count > 0) { + ensureValue(powerBalanceLogs.get(0).timestamp) + ensureValue(powerBalanceLogs.get(powerBalanceLogs.count - 1).timestamp) + } + } + } + upperSeries: LineSeries { + id: consumptionUpperSeries + } + + function addEntry(entry) { + consumptionUpperSeries.append(entry.timestamp.getTime(), entry.consumption) + } + function insertEntry(index, entry) { + consumptionUpperSeries.insert(index, entry.timestamp.getTime(), entry.consumption) + } + } + + Repeater { + id: consumersRepeater + model: consumers + + onCountChanged: { + if (count == consumers.count) { + logsLoader.fetchLogs() + } + } + + delegate: Item { + id: consumerDelegate + + readonly property Thing thing: consumers.get(index) + property AreaSeries series: null + + + function getBaseValue(timestamp) { + var ret = 0 + if (index > 0) { + ret = consumersRepeater.itemAt(index - 1).getBaseValue(timestamp) + } + + var entry = logs.find(timestamp) + if (entry) { + ret += entry.currentPower; + } + return ret + } + + function insertEntry(idx, entry) { + var baseValue = 0; + if (index > 0) { + baseValue = consumersRepeater.itemAt(index - 1).getBaseValue(entry.timestamp) + } + series.upperSeries.insert(idx, entry.timestamp.getTime(), entry.currentPower + baseValue) + } + + readonly property ThingPowerLogs logs: ThingPowerLogs { + engine: _engine + startTime: new Date(d.startTime.getTime() - d.range * 60000) + endTime: new Date(d.endTime.getTime() + d.range * 60000) + sampleRate: d.sampleRate + thingId: consumerDelegate.thing.id + loader: logsLoader + + Component.onCompleted: print("thingpowerlogs completed") + + onLoadingInhibitedChanged: print("Loading...", consumerDelegate.thing.name) + + onEntriesAdded: { + print("Thing entries added", consumerDelegate.thing.name, index, entries.length) + for (var i = 0; i < entries.length; i++) { + var entry = entries[i] +// print("got thing entry", thing.name, entry.timestamp, entry.currentPower, index + i) + + zeroSeries.ensureValue(entry.timestamp) + valueAxis.adjustMax(entry.currentPower) + + consumerDelegate.insertEntry(index + i, entry) + if (entry.timestamp > d.now && new Date().getTime() - d.now.getTime() < 120000) { + d.now = entry.timestamp + } + } + } + + onEntriesRemoved: { + consumerDelegate.series.upperSeries.removePoints(index, count) + zeroSeries.shrink() + } + } + + Component.onCompleted: { + series = chartView.createSeries(ChartView.SeriesTypeArea, thing.name, dateTimeAxis, valueAxis) + series.lowerSeries = index == 0 ? zeroSeries : consumersRepeater.itemAt(index - 1).series.upperSeries + series.upperSeries = lineSeriesComponent.createObject(series) + series.color = NymeaUtils.generateColor(Style.generationBaseColor, index) + series.borderWidth = 0; + series.borderColor = series.color + + } + } + } + } + + 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 + + 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: { + mouseArea.tooltipping = false; + + if (mouseArea.dragging) { + mouseArea.dragging = false; + + for (var i = 0; i < consumersRepeater.count; i++) { + if (consumersRepeater.itemAt(i).logs.fetchingData) { + wheelStopTimer.start() + return; + } + } + + powerBalanceLogs.fetchLogs() + logsLoader.fetchLogs() +// for (var i = 0; i < consumersRepeater.count; i++) { +// consumersRepeater.itemAt(i).logs.fetchLogs() +// } + } + } + + 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(timestamp.getTime() + (d.visibleValues / 2) * d.sampleRate * 60000) + powerBalanceLogs.fetchLogs() + logsLoader.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: { + for (var i = 0; i < consumersRepeater.count; i++) { + if (consumersRepeater.itemAt(i).logs.fetchingData) { + wheelStopTimer.start() + return; + } + } + + powerBalanceLogs.fetchLogs() + logsLoader.fetchLogs() +// for (var i = 0; i < consumersRepeater.count; i++) { +// consumersRepeater.itemAt(i).logs.fetchLogs() +// } + } + } + + Rectangle { + height: parent.height + width: 1 + color: Style.foregroundColor + x: Math.min(mouseArea.width - 1, Math.max(0, mouseArea.mouseX)) + visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging + } + + NymeaToolTip { + id: toolTip + visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging + + backgroundItem: chartView + backgroundRect: Qt.rect(mouseArea.x + toolTip.x, mouseArea.y + toolTip.y, toolTip.width, toolTip.height) + + property int idx: Math.ceil(mouseArea.mouseX * d.visibleValues / mouseArea.width) + property date timestamp: new Date(d.startTime.getTime() + (idx * d.sampleRate * 60000)) + property PowerBalanceLogEntry entry: powerBalanceLogs.find(timestamp) + + property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins + property int xOnLeft: Math.min(mouseArea.width, mouseArea.mouseX) - Style.smallMargins - width + x: xOnRight + width < mouseArea.width ? xOnRight : xOnLeft + property double maxValue: toolTip.entry ? Math.max(0, entry.consumption) : 0 + y: Math.min(Math.max(mouseArea.height - (maxValue * mouseArea.height / valueAxis.max) - height - Style.margins, 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: toolTip.timestamp.toLocaleString(Qt.locale(), Locale.ShortFormat) + font: Style.smallFont + } + RowLayout { + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: consumptionSeries.color + } + Label { + property double rawValue: toolTip.entry ? toolTip.entry.consumption : 0 + property double displayValue: rawValue >= 1000 ? rawValue / 1000 : rawValue + property string unit: rawValue >= 1000 ? "kW" : "W" + text: "%1: %2 %3".arg(qsTr("Total")).arg(displayValue.toFixed(2)).arg(unit) + font: Style.extraSmallFont + } + } + + Repeater { + model: consumers + delegate: RowLayout { + id: consumerToolTipDelegate + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + // color: index >= 0 ? root.colors[index % root.colors.length] : "white" + color: index >= 0 ? NymeaUtils.generateColor(Style.generationBaseColor, index) : "white" + } + + Label { + property ThingPowerLogEntry entry: toolTip.idx >= 0 ? consumersRepeater.itemAt(index).logs.find(toolTip.timestamp) : null + property double rawValue: entry ? entry.currentPower : 0 + property double displayValue: rawValue >= 1000 ? rawValue / 1000 : rawValue + property string unit: rawValue >= 1000 ? "kW" : "W" + text: "%1: %2 %3".arg(model.name).arg(displayValue.toFixed(2)).arg(unit) + font: Style.extraSmallFont + } + } + } + } + } } } } + + } diff --git a/nymea-app/ui/mainviews/energy/PowerBalanceStats.qml b/nymea-app/ui/mainviews/energy/PowerBalanceStats.qml index ce7c59d1..d11f18f3 100644 --- a/nymea-app/ui/mainviews/energy/PowerBalanceStats.qml +++ b/nymea-app/ui/mainviews/energy/PowerBalanceStats.qml @@ -19,48 +19,98 @@ StatsBase { QtObject { id: d - property BarSet consumptionSet: null - property BarSet productionSet: null - property BarSet acquisitionSet: null - property BarSet returnSet: null - } + property var config: root.configs[selectionTabs.currentValue.config] + property int startOffset: 0 - function reload() { - if (selectionTabs.currentValue === undefined) { - return - } - if (engine.thingManager.fetchingData) { - return; + 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: fetchPending || wheelStopTimer.running || powerBalanceLogs.fetchingData + onLoadingChanged: { + if (!loading) { + refresh() + } } - var config = root.configs[selectionTabs.currentValue.config] - print("Loading Power Balance Stats with config:", config.startTime(), config.sampleRate) + onConfigChanged: valueAxis.max = 1 + onStartOffsetChanged: { +// print("updating because of offset change. fetchingData", powerBalanceLogs.fetchingData, "fetchPending", d.fetchPending) + refresh() + } + function refresh() { + if (powerBalanceLogs.loadingInhibited) { + return; + } - powerBalanceLogs.loadingInhibited = true - powerBalanceLogs.sampleRate = config.sampleRate - powerBalanceLogs.startTime = new Date(config.startTime().getTime() - config.sampleRate * 60000) - powerBalanceLogs.loadingInhibited = false - - chartView.reset(); - } - - Connections { - target: engine.thingManager - onFetchingDataChanged: { - print("Thingmanager loaded", engine.thingManager.fetchingData) - if (!engine.thingManager.fetchingData) root.reload() + var upcomingTimestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.config.count) +// print("refreshing config start", d.config.startTime(), "upcoming:", upcomingTimestamp, "fetchPending", d.fetchPending) + 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) + var entry = powerBalanceLogs.find(timestamp) + var previousEntry = powerBalanceLogs.find(previousTimestamp); + if (entry && (previousEntry || !d.loading)) { +// print("found entry:", entry.timestamp, previousEntry) +// print("Acquisition", entry.totalAcquisition) + var consumption = entry.totalConsumption + var production = entry.totalProduction + var acquisition = entry.totalAcquisition + var returned = entry.totalReturn + if (previousEntry) { + consumption -= previousEntry.totalConsumption + production -= previousEntry.totalProduction + acquisition -= previousEntry.totalAcquisition + returned -= previousEntry.totalReturn + } + consumptionSet.replace(i, consumption) + productionSet.replace(i, production) + acquisitionSet.replace(i, acquisition) + returnSet.replace(i, returned) + valueAxis.adjustMax(consumption) + valueAxis.adjustMax(production) + valueAxis.adjustMax(acquisition) + valueAxis.adjustMax(returned) + } else if (timestamp.getTime() == upcomingTimestamp.getTime() && (previousEntry || !d.loading)) { +// print("it's today!") + var consumption = energyManager.totalConsumption + var production = energyManager.totalProduction + var acquisition = energyManager.totalAcquisition + var returned = energyManager.totalReturn + if (previousEntry) { + consumption -= previousEntry.totalConsumption + production -= previousEntry.totalProduction + acquisition -= previousEntry.totalAcquisition + returned -= previousEntry.totalReturn + } + consumptionSet.replace(i, consumption) + productionSet.replace(i, production) + acquisitionSet.replace(i, acquisition) + returnSet.replace(i, returned) + valueAxis.adjustMax(consumption) + valueAxis.adjustMax(production) + valueAxis.adjustMax(acquisition) + valueAxis.adjustMax(returned) + } else { + consumptionSet.replace(i, 0) + productionSet.replace(i, 0) + acquisitionSet.replace(i, 0) + returnSet.replace(i, 0) + } + } } } ColumnLayout { anchors.fill: parent + spacing: 0 Label { Layout.fillWidth: true Layout.margins: Style.smallMargins horizontalAlignment: Text.AlignHCenter text: qsTr("Totals") - } SelectionTabs { @@ -68,422 +118,419 @@ StatsBase { Layout.fillWidth: true Layout.leftMargin: Style.smallMargins Layout.rightMargin: Style.smallMargins + currentIndex: 1 model: ListModel { - Component.onCompleted: { - append({modelData: qsTr("Hours"), config: "hours" }) - append({modelData: qsTr("Days"), config: "days" }) - append({modelData: qsTr("Weeks"), config: "weeks" }) - append({modelData: qsTr("Months"), config: "months" }) - append({modelData: qsTr("Years"), config: "years" }) -// append({modelData: qsTr("Minutes"), config: "minutes" }) - - selectionTabs.currentIndex = 1 - } + 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" } } - onCurrentValueChanged: { - root.reload() + onTabSelected: { + d.startOffset = 0 + powerBalanceLogs.fetchLogs() } } Connections { target: energyManager onPowerBalanceChanged: { - var start = powerBalanceLogs.get(powerBalanceLogs.count - 1 ) -// print("balance changed:", d.consumptionSet, powerBalanceLogs, powerBalanceLogs.count) -// print("updating", start ? start.timestamp : "", start ? start.totalConsumption : 0, root.energyManager.totalConsumption, root.energyManager.totalConsumption - (start ? start.totalConsumption : 0)) - if (root.hasProducers) { - var consumption = root.energyManager.totalConsumption - (start ? start.totalConsumption : 0) - d.consumptionSet.replace(d.consumptionSet.count - 1, consumption) - valueAxis.adjustMax(consumption) - var production = root.energyManager.totalProduction - (start ? start.totalProduction : 0) - d.productionSet.replace(d.productionSet.count - 1, production) - valueAxis.adjustMax(production) - } - var acquisition = root.energyManager.totalAcquisition - (start ? start.totalAcquisition : 0) - if (d.acquisitionSet) { - d.acquisitionSet.replace(d.acquisitionSet.count - 1, acquisition) - } - valueAxis.adjustMax(acquisition) - var ret = root.energyManager.totalReturn - (start ? start.totalReturn : 0) - if (d.returnSet) { - d.returnSet.replace(d.returnSet.count - 1, ret) - } - valueAxis.adjustMax(ret) +// print("updating because of power balance change. fetchingData", powerBalanceLogs.fetchingData, "fetchPending", d.fetchPending) + d.refresh(); } } PowerBalanceLogs { id: powerBalanceLogs engine: _engine - loadingInhibited: true + 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 + Component.onCompleted: fetchLogs() onFetchingDataChanged: { if (!fetchingData) { - chartView.animationOptions = ChartView.NoAnimation - - chartView.reset(); - print("Logs fetched") - var config = root.configs[selectionTabs.currentValue.config] - - var labels = [] - var entries = [] - - var newestLogTimestamp = powerBalanceLogs.count > 0 ? powerBalanceLogs.get(powerBalanceLogs.count - 1).timestamp : new Date(); - for (var i = 0; i < config.count; i++) { - var entry = powerBalanceLogs.get(powerBalanceLogs.count - i - 1) - - // if it's the first, let's add a generated entry which shows the total from the newest log to the current live value - if (i == 0) { - var liveEntry = { - consumption: energyManager.totalConsumption, - production: energyManager.totalProduction, - acquisition: energyManager.totalAcquisition, - returned: energyManager.totalReturn - } - if (entry) { - liveEntry.consumption -= entry.totalConsumption - liveEntry.production -= entry.totalProduction - liveEntry.acquisition -= entry.totalAcquisition - liveEntry.returned -= entry.totalReturn - } -// print("Adding live entry:", liveEntry.consumption, root.energyManager.totalConsumption, entry ? entry.totalConsumption : 0) - entries.unshift(liveEntry) - valueAxis.adjustMax(liveEntry.consumption) - valueAxis.adjustMax(liveEntry.production) - valueAxis.adjustMax(liveEntry.acquisition) - valueAxis.adjustMax(liveEntry.returned) - } - - // Add the actual entry - var graphEntry = { - consumption: 0, - production: 0, - acquisition: 0, - returned: 0 - } - var labelTime = new Date(); - if (entry) { -// print("Have entry:", entry.timestamp, config.toLabel(entry.timestamp)) - var previous = powerBalanceLogs.get(powerBalanceLogs.count - i - 2) - if (previous) { - graphEntry.consumption = entry.totalConsumption - previous.totalConsumption - graphEntry.production = entry.totalProduction - previous.totalProduction - graphEntry.acquisition = entry.totalAcquisition - previous.totalAcquisition - graphEntry.returned = entry.totalReturn - previous.totalReturn - } else { - graphEntry.consumption = entry.totalConsumption - graphEntry.production = entry.totalProduction - graphEntry.acquisition = entry.totalAcquisition - graphEntry.returned = entry.totalReturn - } - labelTime = entry.timestamp - } else { - labelTime = calculateSampleStart(newestLogTimestamp, config.sampleRate, i) - } - -// print("Adding entry:", labelTime, graphEntry.consumption, config.toLabel(labelTime)) - entries.unshift(graphEntry) - labels.unshift(labelTime) - - // Given we've added 2 entries for the first run but only one label, we'll add the missing label - // at the end. This will shift the labels by one entries but that's ok because the logs timestamp - // is when the sample was created, but for the user it's better to show the the consumption values - // *during* that sample, not *before* the sample - if (i == config.count - 1) { - labelTime = new Date(labelTime.getTime() - config.sampleRate * 60000) -// print("Adding oldest entry label", labelTime, config.sampleRate, config.toLabel(labelTime)) - labels.unshift(labelTime) - } - - valueAxis.adjustMax(graphEntry.consumption) - valueAxis.adjustMax(graphEntry.production) - valueAxis.adjustMax(graphEntry.acquisition) - valueAxis.adjustMax(graphEntry.returned) - } - -// print("assigning categories:", labels) - categoryAxis.timestamps = labels - - chartView.animationOptions = NymeaUtils.chartsAnimationOptions - - for (var i = 0; i < entries.length; i++) { - print("Appending to set", JSON.stringify(entries[i])) - if (root.hasProducers) { - d.consumptionSet.append(entries[i].consumption) - d.productionSet.append(entries[i].production) - } - d.acquisitionSet.append(entries[i].acquisition) - d.returnSet.append(entries[i].returned) - } + d.fetchPending = false + d.refresh() } } - onEntryAdded: { + onEntriesAdded: { if (fetchingData) { return } - -// print("Entry added") - var config = root.configs[selectionTabs.currentValue.config] - - - var start = entry - var consumptionValue = root.energyManager.totalConsumption - (start ? start.totalConsumption : 0) - var productionValue = root.energyManager.totalProduction - (start ? start.totalProduction : 0) - var acquisitionValue = root.energyManager.totalAcquisition - (start ? start.totalAcquisition : 0) - var returnValue = root.energyManager.totalReturn - (start ? start.totalReturn : 0) -// print("Entry added:", entry.timestamp, entry.totalConsumption, consumptionValue) - - chartView.animationOptions = ChartView.NoAnimation - - var timestamps = categoryAxis.timestamps; - timestamps.push(entry.timestamp) - timestamps.splice(0, 1) - categoryAxis.timestamps = timestamps - - if (root.hasProducers) { - d.consumptionSet.remove(0, 1); - d.consumptionSet.append(consumptionValue) - d.productionSet.remove(0, 1); - d.productionSet.append(productionValue) - } - d.acquisitionSet.remove(0, 1); - d.acquisitionSet.append(acquisitionValue) - d.returnSet.remove(0, 1); - d.returnSet.append(returnValue) - - chartView.animationOptions = NymeaUtils.chartsAnimationOptions + // Update the timeline by faking a left/right scroll + d.startOffset-- + d.startOffset++ + //d.refresh() } } - - ChartView { - id: chartView + Item { Layout.fillWidth: true Layout.fillHeight: true - animationOptions: ChartView.NoAnimation - backgroundColor: "transparent" - legend.alignment: Qt.AlignBottom - legend.font: Style.extraSmallFont - legend.labelColor: Style.foregroundColor + 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 {} } + } - // margins.left: 0 - margins.right: 0 - margins.bottom: 0 - margins.top: 0 + ChartView { + id: chartView + animationOptions: ChartView.NoAnimation + anchors.fill: parent - function reset() { - barSeries.clear(); - valueAxis.max = 0 - if (root.hasProducers) { - d.consumptionSet = barSeries.append(qsTr("Consumed"), []) - d.consumptionSet.color = Style.blue - d.consumptionSet.borderColor = d.consumptionSet.color - d.consumptionSet.borderWidth = 0 - d.productionSet = barSeries.append(qsTr("Produced"), []) - d.productionSet.color = Style.yellow - d.productionSet.borderColor = d.productionSet.color - d.productionSet.borderWidth = 0 + backgroundColor: "transparent" + legend.alignment: Qt.AlignBottom + legend.font: Style.extraSmallFont + legend.labelColor: Style.foregroundColor + + // margins.left: 0 + margins.right: 0 + margins.bottom: 0 + margins.top: 0 + + ActivityIndicator { + x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2 + y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8) + visible: powerBalanceLogs.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") + visible: !powerBalanceLogs.fetchingData && (powerBalanceLogs.count == 0 || powerBalanceLogs.get(0).timestamp > d.endTime) && d.startOffset != 0 + font: Style.smallFont + opacity: .5 + 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 + } + } + } + + 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 = [] + print("Updating categories from", d.config.startTime()) + for (var i = 0; i < d.config.count; i++) { + var timestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + i); + print("*** adding", timestamp, 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) { + print("adjusting to new max", newValue) + max = newValue // Math.ceil(newValue / 100) * 100 + } + } + } + + BarSet { + id: consumptionSet + label: qsTr("Consumed") + color: Style.blue + borderColor: color + borderWidth: 0 + values: { + var ret = [] + for (var i = 0; i < d.config.count; i++) { + ret.push(0) + } + return ret + } + } + BarSet { + id: productionSet + label: qsTr("Produced") + color: Style.yellow + borderColor: color + borderWidth: 0 + values: { + var ret = [] + for (var i = 0; i < d.config.count; i++) { + ret.push(0) + } + return ret + } + } + + BarSet { + id: acquisitionSet + label: qsTr("From grid") + color: Style.red + borderColor: color + borderWidth: 0 + values: { + var ret = [] + for (var i = 0; i < d.config.count; i++) { + ret.push(0) + } + return ret + } + } + BarSet { + id: returnSet + label: qsTr("To grid") + color: Style.green + borderColor: color + borderWidth: 0 + values: { + var ret = [] + for (var i = 0; i < d.config.count; i++) { + ret.push(0) + } + return ret + } + } } - d.acquisitionSet = barSeries.append(qsTr("From grid"), []) - d.acquisitionSet.color = Style.red - d.acquisitionSet.borderColor = d.acquisitionSet.color - d.acquisitionSet.borderWidth = 0 - d.returnSet = barSeries.append(qsTr("To grid"), []) - d.returnSet.color = Style.green - d.returnSet.borderColor = d.returnSet.color - d.returnSet.borderWidth = 0 } Item { - id: labelsLayout - x: Style.smallMargins - y: chartView.plotArea.y - height: chartView.plotArea.height - width: chartView.plotArea.x - x + 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 - 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 - } + 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.min(Math.max(0,Math.floor(mouseArea.mouseX * categoryAxis.count / mouseArea.width)), categoryAxis.count - 1) + visible: toolTip.visible + + x: idx * parent.width / categoryAxis.count + Behavior on x { enabled: toolTip.animationsEnabled; NumberAnimation { duration: Style.animationDuration } } } } - BarSeries { - id: barSeries - axisX: BarCategoryAxis { - id: categoryAxis - labelsColor: Style.foregroundColor - labelsFont: Style.extraSmallFont - gridVisible: false - gridLineColor: Style.tileOverlayColor - lineVisible: false - titleVisible: false - shadesVisible: false + 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 - categories: { - var ret = [] - for (var i = 0; i < timestamps.length; i++) { - ret.push(root.configs[selectionTabs.currentValue.config].toLabel(timestamps[i])) - } - return ret - } + hoverEnabled: true + preventStealing: tooltipping || dragging - property var timestamps: [] + property int startMouseX: 0 + property bool dragging: false + property bool tooltipping: false + property int dragStartOffset: 0 - } - 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 = newValue // Math.ceil(newValue / 100) * 100 + Timer { + interval: 300 + running: mouseArea.pressed + onTriggered: { + if (!mouseArea.dragging) { + mouseArea.tooltipping = true } } } - } - } - } - 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 + onReleased: { + if (mouseArea.dragging) { + powerBalanceLogs.fetchLogs() + mouseArea.dragging = false; + } - 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.min(Math.max(0,Math.floor(mouseArea.mouseX * categoryAxis.count / mouseArea.width)), categoryAxis.count - 1) - 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 - - Timer { - interval: 300 - running: mouseArea.pressed - onTriggered: mouseArea.preventStealing = true - } - onReleased: mouseArea.preventStealing = false - - 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: Math.min(Math.max(0,Math.floor(mouseArea.mouseX * categoryAxis.count / mouseArea.width)), categoryAxis.count - 1) - visible: mouseArea.containsMouse || mouseArea.preventStealing - - 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: Math.max(d.consumptionSet ? d.consumptionSet.at(idx) : 0, - d.productionSet ? d.productionSet.at(idx) : 0, - d.acquisitionSet ? d.acquisitionSet.at(idx) : 0, - d.returnSet ? d.returnSet.at(idx) : 0) - 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 + mouseArea.tooltipping = false; } - Label { - text: toolTip.idx >= 0 && categoryAxis.timestamps.length > toolTip.idx ? root.configs[selectionTabs.currentValue.config].toLongLabel(categoryAxis.timestamps[toolTip.idx]) : "" - font: Style.smallFont + onPressed: { + startMouseX = mouseX + dragStartOffset = d.startOffset } - RowLayout { - visible: root.hasProducers - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.blue - } - Label { - text: toolTip.visible && d.consumptionSet ? qsTr("Consumed: %1 kWh").arg(d.consumptionSet.at(toolTip.idx).toFixed(2)) : "" - font: Style.extraSmallFont - } + 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) + powerBalanceLogs.fetchLogs(); } - RowLayout { - visible: root.hasProducers - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.yellow + + onMouseXChanged: { + if (!pressed || mouseArea.tooltipping) { + return; } - Label { - text: toolTip.visible && d.productionSet ? qsTr("Produced: %1 kWh").arg(d.productionSet.at(toolTip.idx).toFixed(2)) : "" - font: Style.extraSmallFont + 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; } - RowLayout { - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.red + + property int wheelDelta: 0 + onWheel: { + wheelDelta += wheel.pixelDelta.x + var slotWidth = mouseArea.width / d.config.count + while (wheelDelta > slotWidth) { + d.startOffset-- + wheelDelta -= slotWidth } - Label { - text: toolTip.visible && d.acquisitionSet ? qsTr("From grid: %1 kWh").arg(d.acquisitionSet.at(toolTip.idx).toFixed(2)) : "" - font: Style.extraSmallFont + while (wheelDelta < -slotWidth) { + d.startOffset = Math.min(d.startOffset + 1, 0) + wheelDelta += slotWidth } + d.fetchPending = true; + wheelStopTimer.restart() } - RowLayout { - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.green - } - Label { - text: toolTip.visible && d.returnSet ? qsTr("To grid: %1 kWh").arg(d.returnSet.at(toolTip.idx).toFixed(2)) : "" - font: Style.extraSmallFont + + Timer { + id: wheelStopTimer + interval: 300 + repeat: false + onTriggered: powerBalanceLogs.fetchLogs() + } + + 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: Math.ceil(mouseArea.mouseX * d.config.count / mouseArea.width) - 1 + 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: d.startOffset !== undefined ? Math.max(consumptionSet.at(idx), + productionSet.at(idx), + acquisitionSet.at(idx), + returnSet.at(idx)) : 0 + 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 + } + + RowLayout { + visible: root.hasProducers + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.blue + } + Label { + text: d.startOffset !== undefined ? qsTr("Consumed: %1 kWh").arg(consumptionSet.at(toolTip.idx).toFixed(2)) : "" + font: Style.extraSmallFont + } + } + RowLayout { + visible: root.hasProducers + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.yellow + } + Label { + text: d.startOffset !== undefined ? qsTr("Produced: %1 kWh").arg(productionSet.at(toolTip.idx).toFixed(2)) : "" + font: Style.extraSmallFont + } + } + RowLayout { + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.red + } + Label { + text: d.startOffset !== undefined ? qsTr("From grid: %1 kWh").arg(acquisitionSet.at(toolTip.idx).toFixed(2)) :"" + font: Style.extraSmallFont + } + } + RowLayout { + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.green + } + Label { + text: d.startOffset !== undefined ? qsTr("To grid: %1 kWh").arg(returnSet.at(toolTip.idx).toFixed(2)) : "" + font: Style.extraSmallFont + } + } } } } } } } - diff --git a/nymea-app/ui/mainviews/energy/PowerConsumptionBalanceHistory.qml b/nymea-app/ui/mainviews/energy/PowerConsumptionBalanceHistory.qml index 9e9f9769..311e7f8d 100644 --- a/nymea-app/ui/mainviews/energy/PowerConsumptionBalanceHistory.qml +++ b/nymea-app/ui/mainviews/energy/PowerConsumptionBalanceHistory.qml @@ -2,18 +2,19 @@ import QtQuick 2.0 import QtCharts 2.2 import QtQuick.Layouts 1.2 import QtQuick.Controls 2.2 -import QtGraphicalEffects 1.0 import Nymea 1.0 import "qrc:/ui/components" Item { id: root - property PowerBalanceLogs energyLogs: PowerBalanceLogs { + PowerBalanceLogs { id: powerBalanceLogs engine: _engine - startTime: dateTimeAxis.min - sampleRate: EnergyLogs.SampleRate15Mins + startTime: new Date(d.startTime.getTime() - (d.range * 60 * 1000)) + endTime: new Date(d.endTime.getTime() + (d.range * 60 * 1000)) + sampleRate: d.sampleRate + Component.onCompleted: fetchLogs() } property ThingsProxy batteries: ThingsProxy { @@ -21,350 +22,552 @@ Item { shownInterfaces: ["energystorage"] } - Component.onCompleted: { - for (var i = 0; i < powerBalanceLogs.count; i++) { - var entry = energyLogs.powerBalanceLogs.get(i); - consumptionSeries.addEntry(entry) - selfProductionSeries.addEntry(entry) - storageSeries.addEntry(entry) - acquisitionSeries.addEntry(entry) + QtObject { + id: d + property date now: new Date() + + readonly property int range: selectionTabs.currentValue.range + readonly property int sampleRate: selectionTabs.currentValue.sampleRate + readonly property int visibleValues: range / sampleRate + + readonly property var startTime: { + var date = new Date(now); + date.setTime(date.getTime() - (range * 60 * 1000) + 2000); + print("setting starttime to", date, range) + return date; + } + + readonly property var endTime: { + var date = new Date(now); + date.setTime(date.getTime() + 2000) + print("setting endtime to", date, range) + return date; } } Connections { target: powerBalanceLogs - onEntryAdded: { - consumptionSeries.addEntry(entry) - selfProductionSeries.addEntry(entry) - storageSeries.addEntry(entry) - acquisitionSeries.addEntry(entry) - if (dateTimeAxis.now < entry.timestamp) { - dateTimeAxis.now = entry.timestamp - zeroSeries.update(entry.timestamp) + onEntriesAdded: { +// print("entries added", index, entries.length) + for (var i = 0; i < entries.length; i++) { + var entry = entries[i] +// print("got entry", entry.timestamp) + + zeroSeries.ensureValue(entry.timestamp) + // For debugging, to see if the other maths line up with the plain production graph +// consumptionSeries.insertEntry(index + i, entry) + selfProductionSeries.insertEntry(index + i, entry) + storageSeries.insertEntry(index + i, entry) + acquisitionSeries.insertEntry(index + i, entry) + if (entry.timestamp > d.now && new Date().getTime() - d.now.getTime() < 120000) { + d.now = entry.timestamp + } } } + + onEntriesRemoved: { + acquisitionUpperSeries.removePoints(index, count) + storageUpperSeries.removePoints(index, count) + selfProductionUpperSeries.removePoints(index, count) + consumptionUpperSeries.removePoints(index, count) + zeroSeries.shrink() + } } - Timer { - interval: 60000 - repeat: true - onTriggered: { - var now = new Date() - if (dateTimeAxis.now < now) { - dateTimeAxis.now = now - zeroSeries.update(now) - } - } - } - - ChartView { - id: chartView + ColumnLayout { anchors.fill: parent - backgroundColor: "transparent" - margins.left: 0 - margins.right: 0 - margins.bottom: 0 - margins.top: 0 + spacing: 0 - title: qsTr("My consumption history") - titleColor: Style.foregroundColor - - legend.alignment: Qt.AlignBottom - legend.labelColor: Style.foregroundColor - legend.font: Style.extraSmallFont - - - ValueAxis { - id: valueAxis - min: 0 - max: Math.ceil(powerBalanceLogs.maxValue / 1000) * 1000 - labelFormat: "" - gridLineColor: Style.tileOverlayColor - labelsVisible: false - lineVisible: false - titleVisible: false - shadesVisible: false - // visible: false + Label { + Layout.fillWidth: true + Layout.margins: Style.smallMargins + horizontalAlignment: Text.AlignHCenter + text: qsTr("My consumption history") + } + SelectionTabs { + id: selectionTabs + Layout.fillWidth: true + Layout.leftMargin: Style.smallMargins + Layout.rightMargin: Style.smallMargins + currentIndex: 1 + model: ListModel { + ListElement { + modelData: qsTr("Hours") + sampleRate: EnergyLogs.SampleRate1Min + range: 180 // 3 Hours: 3 * 60 + } + ListElement { + modelData: qsTr("Days") + sampleRate: EnergyLogs.SampleRate15Mins + range: 1440 // 1 Day: 24 * 60 + } + ListElement { + modelData: qsTr("Weeks") + sampleRate: EnergyLogs.SampleRate1Hour + range: 10080 // 7 Days: 7 * 24 * 60 + } + ListElement { + modelData: qsTr("Months") + sampleRate: EnergyLogs.SampleRate3Hours + range: 43200 // 30 Days: 30 * 24 * 60 + } + } + onTabSelected: { + d.now = new Date() + powerBalanceLogs.fetchLogs() + } } 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))) / 1000).toFixed(2) + "kW" - verticalAlignment: Text.AlignTop - font: Style.extraSmallFont - } + 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.startTime.toLocaleDateString(Qt.locale(), Locale.LongFormat) + font: Style.smallFont + opacity: ((new Date().getTime() - d.now.getTime()) / d.sampleRate / 60000) > d.visibleValues ? .5 : 0 + Behavior on opacity { NumberAnimation {} } } - } + ChartView { + id: chartView + anchors.fill: parent + backgroundColor: "transparent" + margins.left: 0 + margins.right: 0 + margins.bottom: 0 + margins.top: 0 - DateTimeAxis { - id: dateTimeAxis - property date now: new Date() - min: { - var date = new Date(now); - date.setTime(date.getTime() - (1000 * 60 * 60 * 24) + 2000); - return date; - } - max: { - var date = new Date(now); - date.setTime(date.getTime() + 2000) - return date; - } - format: "hh:mm" - labelsFont: Style.extraSmallFont - gridVisible: false - minorGridVisible: false - lineVisible: false - shadesVisible: false - labelsColor: Style.foregroundColor - } + legend.alignment: Qt.AlignBottom + legend.labelColor: Style.foregroundColor + legend.font: Style.extraSmallFont - // For debugging, to see the total graph and check if the other maths line up - AreaSeries { - id: consumptionSeries - axisX: dateTimeAxis - axisY: valueAxis - color: "blue" - borderWidth: 0 - borderColor: color - opacity: .5 - visible: false - - lowerSeries: zeroSeries - upperSeries: LineSeries { - id: consumptionUpperSeries - } - - function calculateValue(entry) { - return entry.consumption - } - function addEntry(entry) { - consumptionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) - } - } - - - AreaSeries { - id: selfProductionSeries - axisX: dateTimeAxis - axisY: valueAxis - color: Style.green - borderWidth: 0 - borderColor: color - name: qsTr("Self production") - // visible: false - - lowerSeries: LineSeries { - id: zeroSeries - XYPoint { x: dateTimeAxis.min.getTime(); y: 0 } - XYPoint { x: dateTimeAxis.max.getTime(); y: 0 } - function update(timestamp) { - append(timestamp, 0); - removePoints(1,1); - } - } - - upperSeries: LineSeries { - id: selfProductionUpperSeries - } - - function calculateValue(entry) { - var value = entry.consumption - Math.max(0, entry.acquisition); - if (entry.storage < 0) { - value += entry.storage; - } - return value; - } - - function addEntry(entry) { - selfProductionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) - } - } - - AreaSeries { - id: storageSeries - axisX: dateTimeAxis - axisY: valueAxis - color: Style.orange - borderWidth: 0 - borderColor: color - name: qsTr("From battery") - visible: root.batteries.count > 0 - - lowerSeries: selfProductionUpperSeries - upperSeries: LineSeries { - id: storageUpperSeries - } - - function calculateValue(entry) { - return selfProductionSeries.calculateValue(entry) + Math.abs(Math.min(0, entry.storage)); - } - - function addEntry(entry) { - storageUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) - } - } - - - AreaSeries { - id: acquisitionSeries - axisX: dateTimeAxis - axisY: valueAxis - color: Style.red - borderWidth: 0 - borderColor: color - name: qsTr("From grid") - // visible: false - - lowerSeries: storageUpperSeries - upperSeries: LineSeries { - id: acquisitionUpperSeries - } - - function calculateValue(entry) { - return storageSeries.calculateValue(entry) + Math.max(0, entry.acquisition) - } - function addEntry(entry) { - acquisitionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) - } - } - } - - - MouseArea { - id: mouseArea - anchors.fill: chartView - 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 - - Timer { - interval: 300 - running: mouseArea.pressed - onTriggered: mouseArea.preventStealing = true - } - onReleased: mouseArea.preventStealing = false - - Rectangle { - height: parent.height - width: 1 - color: Style.foregroundColor - x: Math.min(mouseArea.width - 1, Math.max(0, mouseArea.mouseX)) - visible: mouseArea.containsMouse || mouseArea.preventStealing - } - - - NymeaToolTip { - id: toolTip - visible: mouseArea.containsMouse || mouseArea.preventStealing - - backgroundItem: chartView - backgroundRect: Qt.rect(mouseArea.x + toolTip.x, mouseArea.y + toolTip.y, toolTip.width, toolTip.height) - - - property int idx: consumptionUpperSeries.count - (Math.floor(mouseArea.mouseX * consumptionUpperSeries.count / mouseArea.width)) - property int seriesIndex: Math.min(consumptionUpperSeries.count - 1, Math.max(0, consumptionUpperSeries.count - idx)) - - property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins - property int xOnLeft: Math.min(mouseArea.mouseX, mouseArea.width) - Style.smallMargins - width - x: xOnRight + width < mouseArea.width ? xOnRight : xOnLeft - property double maxValue: consumptionUpperSeries.at(seriesIndex).y - y: Math.min(Math.max(mouseArea.height - (maxValue * mouseArea.height / valueAxis.max) - height - Style.margins, 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 + ActivityIndicator { + x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2 + y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8) + visible: powerBalanceLogs.fetchingData + opacity: .5 } Label { - text: new Date(consumptionUpperSeries.at(toolTip.seriesIndex).x).toLocaleString(Qt.locale(), Locale.ShortFormat) + 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") + visible: !powerBalanceLogs.fetchingData && (powerBalanceLogs.count == 0 || powerBalanceLogs.get(0).timestamp > d.now) font: Style.smallFont + opacity: .5 } - Label { - property double value: acquisitionUpperSeries.at(toolTip.seriesIndex).y - property bool translate: value >= 1000 - property double translatedValue: value / (translate ? 1000 : 1) - text: qsTr("Total consumption: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") - font: Style.extraSmallFont + ValueAxis { + id: valueAxis + min: 0 + max: Math.ceil(powerBalanceLogs.maxValue / 100) * 100 + labelFormat: "" + gridLineColor: Style.tileOverlayColor + labelsVisible: false + lineVisible: false + titleVisible: false + shadesVisible: false + // visible: false + } - RowLayout { - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.green + 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))) / 1000).toFixed(2) + "kW" + verticalAlignment: Text.AlignTop + font: Style.extraSmallFont + } } - Label { - // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings - Component.onCompleted: lowerSeries = selfProductionSeries.lowerSeries - property XYSeries lowerSeries: null + } - property double value: selfProductionUpperSeries.at(toolTip.seriesIndex).y - lowerSeries.at(toolTip.seriesIndex).y - property bool translate: value >= 1000 - property double translatedValue: value / (translate ? 1000 : 1) - text: qsTr("Self production: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") - font: Style.extraSmallFont + DateTimeAxis { + id: dateTimeAxis + min: d.startTime + max: d.endTime + format: { + switch (selectionTabs.currentValue.sampleRate) { + case EnergyLogs.SampleRate1Min: + case EnergyLogs.SampleRate15Mins: + return "hh:mm" + case EnergyLogs.SampleRate1Hour: + case EnergyLogs.SampleRate3Hours: + case EnergyLogs.SampleRate1Day: + return "dd.MM." + } + } + tickCount: { + switch (selectionTabs.currentValue.sampleRate) { + case EnergyLogs.SampleRate1Min: + case EnergyLogs.SampleRate15Mins: + return root.width > 500 ? 13 : 7 + case EnergyLogs.SampleRate1Hour: + return 7 + case EnergyLogs.SampleRate3Hours: + case EnergyLogs.SampleRate1Day: + return root.width > 500 ? 12 : 6 + } + } + labelsFont: Style.extraSmallFont + gridVisible: false + minorGridVisible: false + lineVisible: false + shadesVisible: false + labelsColor: Style.foregroundColor + } + + // For debugging, to see the total graph and check if the other maths line up + AreaSeries { + id: consumptionSeries + axisX: dateTimeAxis + axisY: valueAxis + color: "blue" + borderWidth: 0 + borderColor: color + opacity: .5 + visible: false + + lowerSeries: zeroSeries + upperSeries: LineSeries { + id: consumptionUpperSeries + } + + function calculateValue(entry) { + return entry.consumption + } + function addEntry(entry) { + consumptionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + } + function insertEntry(index, entry) { + consumptionUpperSeries.insert(index, entry.timestamp.getTime(), calculateValue(entry)) } } - RowLayout { + + + AreaSeries { + id: selfProductionSeries + axisX: dateTimeAxis + axisY: valueAxis + color: Style.green + borderWidth: 0 + borderColor: color + name: qsTr("Self production") + // visible: false + + lowerSeries: LineSeries { + id: zeroSeries + XYPoint { x: dateTimeAxis.min.getTime(); y: 0 } + XYPoint { x: dateTimeAxis.max.getTime(); y: 0 } + function ensureValue(timestamp) { + if (count == 0) { + append(timestamp, 0) + } else if (count == 1) { + if (timestamp.getTime() < at(0).x) { + insert(0, timestamp, 0) + } else { + append(timestamp, 0) + } + } else { + if (timestamp.getTime() < at(0).x) { + remove(0) + insert(0, timestamp, 0) + } else if (timestamp.getTime() > at(1).x) { + remove(1) + append(timestamp, 0) + } + } + } + function shrink() { + clear(); + if (powerBalanceLogs.count > 0) { + ensureValue(powerBalanceLogs.get(0).timestamp) + ensureValue(powerBalanceLogs.get(powerBalanceLogs.count-1).timestamp) + } + } + } + + upperSeries: LineSeries { + id: selfProductionUpperSeries + } + + function calculateValue(entry) { + var value = entry.consumption - Math.max(0, entry.acquisition); + if (entry.storage < 0) { + value += entry.storage; + } + return value; + } + + function addEntry(entry) { + selfProductionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + } + function insertEntry(index, entry) { + selfProductionUpperSeries.insert(index, entry.timestamp.getTime(), calculateValue(entry)) + } + } + + AreaSeries { + id: storageSeries + axisX: dateTimeAxis + axisY: valueAxis + color: Style.orange + borderWidth: 0 + borderColor: color + name: qsTr("From battery") visible: root.batteries.count > 0 - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.orange + + lowerSeries: selfProductionUpperSeries + upperSeries: LineSeries { + id: storageUpperSeries } - Label { - // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings - Component.onCompleted: lowerSeries = storageSeries.lowerSeries - property XYSeries lowerSeries: null + function calculateValue(entry) { + return selfProductionSeries.calculateValue(entry) + Math.abs(Math.min(0, entry.storage)); + } - property double value: storageUpperSeries.at(toolTip.seriesIndex).y - lowerSeries.at(toolTip.seriesIndex).y - property bool translate: value >= 1000 - property double translatedValue: value / (translate ? 1000 : 1) - text: qsTr("From battery: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") - font: Style.extraSmallFont + function addEntry(entry) { + storageUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + } + function insertEntry(index, entry) { + storageUpperSeries.insert(index, entry.timestamp.getTime(), calculateValue(entry)) } } - RowLayout { - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.red + + + AreaSeries { + id: acquisitionSeries + axisX: dateTimeAxis + axisY: valueAxis + color: Style.red + borderWidth: 0 + borderColor: color + name: qsTr("From grid") + // visible: false + + lowerSeries: storageUpperSeries + upperSeries: LineSeries { + id: acquisitionUpperSeries } - Label { - // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings - Component.onCompleted: lowerSeries = acquisitionSeries.lowerSeries - property XYSeries lowerSeries: null + function calculateValue(entry) { + return storageSeries.calculateValue(entry) + Math.max(0, entry.acquisition) + } + function addEntry(entry) { + acquisitionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + } + function insertEntry(index, entry) { + acquisitionUpperSeries.insert(index, entry.timestamp.getTime(), calculateValue(entry)) + } + } + } - property double value: acquisitionUpperSeries.at(toolTip.seriesIndex).y - lowerSeries.at(toolTip.seriesIndex).y - property bool translate: value >= 1000 - property double translatedValue: value / (translate ? 1000 : 1) - text: qsTr("From grid: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") - font: Style.extraSmallFont + + + MouseArea { + id: mouseArea + anchors.fill: chartView + 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 + + 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) { + powerBalanceLogs.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(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: powerBalanceLogs.fetchLogs() + } + + Rectangle { + height: parent.height + width: 1 + color: Style.foregroundColor + x: Math.min(mouseArea.width, Math.max(0, mouseArea.mouseX)) + visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging + } + + NymeaToolTip { + id: toolTip + visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging + + backgroundItem: chartView + backgroundRect: Qt.rect(mouseArea.x + toolTip.x, mouseArea.y + toolTip.y, toolTip.width, toolTip.height) + + property int idx: Math.ceil(mouseArea.mouseX * d.visibleValues / mouseArea.width) + property var timestamp: new Date(d.startTime.getTime() + (idx * d.sampleRate * 60000)) + property PowerBalanceLogEntry entry: powerBalanceLogs.find(timestamp) + + property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins + property int xOnLeft: Math.min(mouseArea.mouseX, mouseArea.width) - Style.smallMargins - width + x: xOnRight + width < mouseArea.width ? xOnRight : xOnLeft + property double maxValue: toolTip.entry ? Math.max(0, entry.consumption) : 0 + y: Math.min(Math.max(mouseArea.height - (maxValue * mouseArea.height / valueAxis.max) - height - Style.margins, 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: toolTip.timestamp.toLocaleString(Qt.locale(), Locale.ShortFormat) + font: Style.smallFont + } + + Label { + property double value: toolTip.entry ? Math.max(0, toolTip.entry.consumption) : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: qsTr("Total consumption: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") + font: Style.extraSmallFont + } + + RowLayout { + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.green + } + + Label { + // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings + Component.onCompleted: lowerSeries = selfProductionSeries.lowerSeries + property XYSeries lowerSeries: null + + property double value: toolTip.entry ? Math.max(0, -toolTip.entry.production) : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: qsTr("Self production: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") + font: Style.extraSmallFont + } + } + RowLayout { + visible: root.batteries.count > 0 + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.orange + } + + Label { + // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings + Component.onCompleted: lowerSeries = storageSeries.lowerSeries + property XYSeries lowerSeries: null + + property double value: toolTip.entry ? Math.max(0, -toolTip.entry.storage) : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: qsTr("From battery: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") + font: Style.extraSmallFont + } + } + RowLayout { + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.red + } + + Label { + // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings + Component.onCompleted: lowerSeries = acquisitionSeries.lowerSeries + property XYSeries lowerSeries: null + + property double value: toolTip.entry ? Math.max(0, toolTip.entry.acquisition) : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: qsTr("From grid: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") + font: Style.extraSmallFont + } + } } } } diff --git a/nymea-app/ui/mainviews/energy/PowerProductionBalanceHistory.qml b/nymea-app/ui/mainviews/energy/PowerProductionBalanceHistory.qml index 5ab5f352..459ee239 100644 --- a/nymea-app/ui/mainviews/energy/PowerProductionBalanceHistory.qml +++ b/nymea-app/ui/mainviews/energy/PowerProductionBalanceHistory.qml @@ -8,10 +8,13 @@ import "qrc:/ui/components" Item { id: root - property PowerBalanceLogs energyLogs: PowerBalanceLogs { + PowerBalanceLogs { id: powerBalanceLogs engine: _engine - startTime: dateTimeAxis.min + startTime: new Date(d.startTime.getTime() - d.range * 60000) + endTime: new Date(d.endTime.getTime() + d.range * 60000) + sampleRate: d.sampleRate + Component.onCompleted: fetchLogs() } property ThingsProxy batteries: ThingsProxy { @@ -19,347 +22,550 @@ Item { shownInterfaces: ["energystorage"] } - Component.onCompleted: { - for (var i = 0; i < powerBalanceLogs.count; i++) { - var entry = energyLogs.powerBalanceLogs.get(i); - productionSeries.addEntry(entry) - selfConsumptionSeries.addEntry(entry) - storageSeries.addEntry(entry) - acquisitionSeries.addEntry(entry) + QtObject { + id: d + property date now: new Date() + + readonly property int range: selectionTabs.currentValue.range + readonly property int sampleRate: selectionTabs.currentValue.sampleRate + readonly property int visibleValues: range / sampleRate + + readonly property var startTime: { + var date = new Date(now); + date.setTime(date.getTime() - range * 60000 + 2000); + return date; + } + + readonly property var endTime: { + var date = new Date(now); + date.setTime(date.getTime() + 2000) + return date; } } Connections { target: powerBalanceLogs - onEntryAdded: { - productionSeries.addEntry(entry) - selfConsumptionSeries.addEntry(entry) - storageSeries.addEntry(entry) - acquisitionSeries.addEntry(entry) - if (dateTimeAxis.now < entry.timestamp) { - dateTimeAxis.now = entry.timestamp - zeroSeries.update(entry.timestamp) + onEntriesAdded: { +// print("entries added", index, entries.length) + for (var i = 0; i < entries.length; i++) { + var entry = entries[i] +// print("got entry", entry.timestamp) + + zeroSeries.ensureValue(entry.timestamp) + // For debugging, to see if the other maths line up with the plain production graph +// productionSeries.insertEntry(index + i, entry) + selfConsumptionSeries.insertEntry(index + i, entry) + storageSeries.insertEntry(index + i, entry) + acquisitionSeries.insertEntry(index + i, entry) + if (entry.timestamp > d.now && new Date().getTime() - d.now.getTime() < 120000) { + d.now = entry.timestamp + } } } + + onEntriesRemoved: { + acquisitionUpperSeries.removePoints(index, count) + storageUpperSeries.removePoints(index, count) + selfConsumptionUpperSeries.removePoints(index, count) + productionUpperSeries.removePoints(index, count) + zeroSeries.shrink() + } } - Timer { - interval: 60000 - repeat: true - onTriggered: { - var now = new Date() - if (dateTimeAxis.now < now) { - dateTimeAxis.now = now - zeroSeries.update(now) - } - } - } - - ChartView { - id: chartView + ColumnLayout { anchors.fill: parent + spacing: 0 - backgroundColor: "transparent" - margins.left: 0 - margins.right: 0 - margins.bottom: 0 - margins.top: 0 + Label { + Layout.fillWidth: true + Layout.margins: Style.smallMargins + horizontalAlignment: Text.AlignHCenter + text: qsTr("My production history") + } - title: qsTr("My production history") - titleColor: Style.foregroundColor - - legend.alignment: Qt.AlignBottom - legend.labelColor: Style.foregroundColor - legend.font: Style.extraSmallFont - - - ValueAxis { - id: valueAxis - min: 0 - max: Math.ceil(-powerBalanceLogs.minValue / 1000) * 1000 - labelFormat: "" - gridLineColor: Style.tileOverlayColor - labelsVisible: false - lineVisible: false - titleVisible: false - shadesVisible: false + SelectionTabs { + id: selectionTabs + Layout.fillWidth: true + Layout.leftMargin: Style.smallMargins + Layout.rightMargin: Style.smallMargins + currentIndex: 1 + model: ListModel { + ListElement { + modelData: qsTr("Hours") + sampleRate: EnergyLogs.SampleRate1Min + range: 180 // 3 Hours: 3 * 60 + } + ListElement { + modelData: qsTr("Days") + sampleRate: EnergyLogs.SampleRate15Mins + range: 1440 // 1 Day: 24 * 60 + } + ListElement { + modelData: qsTr("Weeks") + sampleRate: EnergyLogs.SampleRate1Hour + range: 10080 // 7 Days: 7 * 24 * 60 + } + ListElement { + modelData: qsTr("Months") + sampleRate: EnergyLogs.SampleRate3Hours + range: 43200 // 30 Days: 30 * 24 * 60 + } + } + onTabSelected: { + d.now = new Date() + powerBalanceLogs.fetchLogs() + } } 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))) / 1000).toFixed(2) + "kW" - verticalAlignment: Text.AlignTop - font: Style.extraSmallFont - } - } - } + Layout.fillWidth: true + Layout.fillHeight: true - DateTimeAxis { - id: dateTimeAxis - property date now: new Date() - min: { - var date = new Date(now); - date.setTime(date.getTime() - (1000 * 60 * 60 * 24) + 2000); - return date; - } - max: { - var date = new Date(now); - date.setTime(date.getTime() + 2000) - return date; - } - format: "hh:mm" - labelsFont: Style.extraSmallFont - gridVisible: false - minorGridVisible: false - lineVisible: false - shadesVisible: false - labelsColor: Style.foregroundColor - } - - // For debugging, to see if the other maths line up with the plain production graph - AreaSeries { - id: productionSeries - axisX: dateTimeAxis - axisY: valueAxis - color: "blue" - borderWidth: 0 - borderColor: color - opacity: .5 - name: "Total production" - visible: false - - function calculateValue(entry) { - return Math.abs(Math.min(0, entry.production)) - } - function addEntry(entry) { - productionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + Label { + x: chartView.x + chartView.plotArea.x + (chartView.plotArea.width - width) / 2 + y: chartView.y + chartView.plotArea.y + Style.smallMargins + text: d.startTime.toLocaleDateString(Qt.locale(), Locale.LongFormat) + font: Style.smallFont + opacity: ((new Date().getTime() - d.now.getTime()) / d.sampleRate / 60000) > d.visibleValues ? .5 : 0 + Behavior on opacity { NumberAnimation {} } } - lowerSeries: zeroSeries - upperSeries: LineSeries { - id: productionUpperSeries - } - } + ChartView { + id: chartView + anchors.fill: parent + backgroundColor: "transparent" + margins.left: 0 + margins.right: 0 + margins.bottom: 0 + margins.top: 0 - AreaSeries { - id: selfConsumptionSeries - axisX: dateTimeAxis - axisY: valueAxis - color: Style.red - borderWidth: 0 - borderColor: color - name: qsTr("Consumed") - // visible: false + legend.alignment: Qt.AlignBottom + legend.labelColor: Style.foregroundColor + legend.font: Style.extraSmallFont - function calculateValue(entry) { - return Math.abs(Math.min(0, entry.production)) - Math.abs(Math.min(0, entry.acquisition)) - Math.max(0, entry.storage) - } - - function addEntry(entry) { - selfConsumptionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) - } - - lowerSeries: LineSeries { - id: zeroSeries - XYPoint { x: dateTimeAxis.min.getTime(); y: 0 } - XYPoint { x: dateTimeAxis.max.getTime(); y: 0 } - function update(timestamp) { - append(timestamp, 0); - removePoints(1,1); - } - } - - upperSeries: LineSeries { - id: selfConsumptionUpperSeries - } - } - - AreaSeries { - id: storageSeries - axisX: dateTimeAxis - axisY: valueAxis - color: Style.orange - borderWidth: 0 - borderColor: color - visible: root.batteries.count > 0 - name: qsTr("To battery") - - - function calculateValue(entry) { - return selfConsumptionSeries.calculateValue(entry) + Math.abs(Math.max(0, entry.storage)); - } - - function addEntry(entry) { - storageUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) - } - - lowerSeries: selfConsumptionUpperSeries - upperSeries: LineSeries { - id: storageUpperSeries - } - } - - - AreaSeries { - id: acquisitionSeries - axisX: dateTimeAxis - axisY: valueAxis - color: Style.green - borderWidth: 0 - borderColor: color - name: qsTr("To grid") - // visible: false - - function calculateValue(entry) { - return storageSeries.calculateValue(entry) + Math.abs(Math.min(0, entry.acquisition)) - } - function addEntry(entry) { - acquisitionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) - } - - lowerSeries: storageUpperSeries - upperSeries: LineSeries { - id: acquisitionUpperSeries - } - } - } - - 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 - - Timer { - interval: 300 - running: mouseArea.pressed - onTriggered: mouseArea.preventStealing = true - } - onReleased: mouseArea.preventStealing = false - - Rectangle { - height: parent.height - width: 1 - color: Style.foregroundColor - x: Math.min(mouseArea.width, Math.max(0, mouseArea.mouseX)) - visible: mouseArea.containsMouse || mouseArea.preventStealing - } - - NymeaToolTip { - id: toolTip - visible: mouseArea.containsMouse || mouseArea.preventStealing - - backgroundItem: chartView - backgroundRect: Qt.rect(mouseArea.x + toolTip.x, mouseArea.y + toolTip.y, toolTip.width, toolTip.height) - - property int idx: productionUpperSeries.count - Math.floor(mouseArea.mouseX * productionUpperSeries.count / mouseArea.width) - property int seriesIndex: Math.min(productionUpperSeries.count - 1, Math.max(0, productionUpperSeries.count - idx)) - - property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins - property int xOnLeft: Math.min(mouseArea.mouseX, mouseArea.width) - Style.smallMargins - width - x: xOnRight + width < mouseArea.width ? xOnRight : xOnLeft - property double maxValue: productionUpperSeries.at(seriesIndex).y - y: Math.min(Math.max(mouseArea.height - (maxValue * mouseArea.height / valueAxis.max) - height - Style.margins, 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 + ActivityIndicator { + x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2 + y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8) + visible: powerBalanceLogs.fetchingData + opacity: .5 } Label { - text: new Date(selfConsumptionUpperSeries.at(toolTip.seriesIndex).x).toLocaleString(Qt.locale(), Locale.ShortFormat) + 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") + visible: !powerBalanceLogs.fetchingData && (powerBalanceLogs.count == 0 || powerBalanceLogs.get(0).timestamp > d.now) font: Style.smallFont + opacity: .5 } - Label { - property double value: acquisitionUpperSeries.at(toolTip.seriesIndex).y - property bool translate: value >= 1000 - property double translatedValue: value / (translate ? 1000 : 1) - text: qsTr("Total production: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") - font: Style.extraSmallFont + ValueAxis { + id: valueAxis + min: 0 + max: Math.ceil(-powerBalanceLogs.minValue / 100) * 100 + labelFormat: "" + gridLineColor: Style.tileOverlayColor + labelsVisible: false + lineVisible: false + titleVisible: false + shadesVisible: false } - - RowLayout { - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.red - } - - Label { - // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings - Component.onCompleted: lowerSeries = selfConsumptionSeries.lowerSeries - property XYSeries lowerSeries: null - - property double value: selfConsumptionUpperSeries.at(toolTip.seriesIndex).y - lowerSeries.at(toolTip.seriesIndex).y - property bool translate: value >= 1000 - property double translatedValue: value / (translate ? 1000 : 1) - text: qsTr("Consumed: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") - font: Style.extraSmallFont + 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))) / 1000).toFixed(2) + "kW" + verticalAlignment: Text.AlignTop + font: Style.extraSmallFont + } } } - RowLayout { + + DateTimeAxis { + id: dateTimeAxis + min: d.startTime + max: d.endTime + format: { + switch (selectionTabs.currentValue.sampleRate) { + case EnergyLogs.SampleRate1Min: + case EnergyLogs.SampleRate15Mins: + return "hh:mm" + case EnergyLogs.SampleRate1Hour: + case EnergyLogs.SampleRate3Hours: + case EnergyLogs.SampleRate1Day: + return "dd.MM." + } + } + tickCount: { + switch (selectionTabs.currentValue.sampleRate) { + case EnergyLogs.SampleRate1Min: + case EnergyLogs.SampleRate15Mins: + return root.width > 500 ? 13 : 7 + case EnergyLogs.SampleRate1Hour: + return 7 + case EnergyLogs.SampleRate3Hours: + case EnergyLogs.SampleRate1Day: + return root.width > 500 ? 12 : 6 + } + } + labelsFont: Style.extraSmallFont + gridVisible: false + minorGridVisible: false + lineVisible: false + shadesVisible: false + labelsColor: Style.foregroundColor + } + + // For debugging, to see if the other maths line up with the plain production graph + AreaSeries { + id: productionSeries + axisX: dateTimeAxis + axisY: valueAxis + color: "blue" + borderWidth: 0 + borderColor: color + opacity: .5 + name: "Total production" + visible: false + + function calculateValue(entry) { + return Math.abs(Math.min(0, entry.production)) + } + function addEntry(entry) { + productionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + } + function insertEntry(index, entry) { + productionUpperSeries.insert(index, entry.timestamp.getTime(), calculateValue(entry)) + } + + lowerSeries: zeroSeries + upperSeries: LineSeries { + id: productionUpperSeries + } + } + + AreaSeries { + id: selfConsumptionSeries + axisX: dateTimeAxis + axisY: valueAxis + color: Style.red + borderWidth: 0 + borderColor: color + name: qsTr("Consumed") + // visible: false + + lowerSeries: LineSeries { + id: zeroSeries + XYPoint { x: dateTimeAxis.min.getTime(); y: 0 } + XYPoint { x: dateTimeAxis.max.getTime(); y: 0 } + function ensureValue(timestamp) { + if (count == 0) { + append(timestamp, 0) + } else if (count == 1) { + if (timestamp.getTime() < at(0).x) { + insert(0, timestamp, 0) + } else { + append(timestamp, 0) + } + } else { + if (timestamp.getTime() < at(0).x) { + remove(0) + insert(0, timestamp, 0) + } else if (timestamp.getTime() > at(1).x) { + remove(1) + append(timestamp, 0) + } + } + } + function shrink() { + clear(); + if (powerBalanceLogs.count > 0) { + ensureValue(powerBalanceLogs.get(0).timestamp) + ensureValue(powerBalanceLogs.get(powerBalanceLogs.count-1).timestamp) + } + } + } + + upperSeries: LineSeries { + id: selfConsumptionUpperSeries + } + + + function calculateValue(entry) { + return Math.max(0, -entry.production) - Math.max(0, -entry.acquisition) - Math.max(0, entry.storage) + } + + function addEntry(entry) { + selfConsumptionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + } + function insertEntry(index, entry) { + selfConsumptionUpperSeries.insert(index, entry.timestamp.getTime(), calculateValue(entry)) + } + } + + AreaSeries { + id: storageSeries + axisX: dateTimeAxis + axisY: valueAxis + color: Style.orange + borderWidth: 0 + borderColor: color visible: root.batteries.count > 0 - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.orange + name: qsTr("To battery") + + + function calculateValue(entry) { + return selfConsumptionSeries.calculateValue(entry) + Math.max(0, entry.storage); } - Label { - // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings - Component.onCompleted: lowerSeries = storageSeries.lowerSeries - property XYSeries lowerSeries: null + function addEntry(entry) { + storageUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + } + function insertEntry(index, entry) { + storageUpperSeries.insert(index, entry.timestamp.getTime(), calculateValue(entry)) + } - property double value: storageUpperSeries.at(toolTip.seriesIndex).y - lowerSeries.at(toolTip.seriesIndex).y - property bool translate: value >= 1000 - property double translatedValue: value / (translate ? 1000 : 1) - text: qsTr("To battery: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") - font: Style.extraSmallFont + lowerSeries: selfConsumptionUpperSeries + upperSeries: LineSeries { + id: storageUpperSeries } } - RowLayout { - Rectangle { - width: Style.extraSmallFont.pixelSize - height: width - color: Style.green + + + AreaSeries { + id: acquisitionSeries + axisX: dateTimeAxis + axisY: valueAxis + color: Style.green + borderWidth: 0 + borderColor: color + name: qsTr("To grid") + // visible: false + + function calculateValue(entry) { + return storageSeries.calculateValue(entry) + Math.max(0, -entry.acquisition) + } + function addEntry(entry) { + acquisitionUpperSeries.append(entry.timestamp.getTime(), calculateValue(entry)) + } + function insertEntry(index, entry) { + acquisitionUpperSeries.insert(index, entry.timestamp.getTime(), calculateValue(entry)) } - Label { - // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings - Component.onCompleted: lowerSeries = acquisitionSeries.lowerSeries - property XYSeries lowerSeries: null - - property double value: acquisitionUpperSeries.at(toolTip.seriesIndex).y - lowerSeries.at(toolTip.seriesIndex).y - property bool translate: value >= 1000 - property double translatedValue: value / (translate ? 1000 : 1) - text: qsTr("To grid: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") - font: Style.extraSmallFont + lowerSeries: storageUpperSeries + upperSeries: LineSeries { + id: acquisitionUpperSeries } } } + + 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 + + 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) { + powerBalanceLogs.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(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: powerBalanceLogs.fetchLogs() + } + + Rectangle { + height: parent.height + width: 1 + color: Style.foregroundColor + x: Math.min(mouseArea.width, Math.max(0, mouseArea.mouseX)) + visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging + } + + NymeaToolTip { + id: toolTip + visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging + + backgroundItem: chartView + backgroundRect: Qt.rect(mouseArea.x + toolTip.x, mouseArea.y + toolTip.y, toolTip.width, toolTip.height) + + property int idx: Math.ceil(mouseArea.mouseX * d.visibleValues / mouseArea.width) + property var timestamp: new Date(d.startTime.getTime() + (idx * d.sampleRate * 60000)) + property PowerBalanceLogEntry entry: powerBalanceLogs.find(timestamp) + + property int xOnRight: Math.max(0, mouseArea.mouseX) + Style.smallMargins + property int xOnLeft: Math.min(mouseArea.mouseX, mouseArea.width) - Style.smallMargins - width + x: xOnRight + width < mouseArea.width ? xOnRight : xOnLeft + property double maxValue: toolTip.entry ? Math.max(0, -entry.production) : 0 + y: Math.min(Math.max(mouseArea.height - (maxValue * mouseArea.height / valueAxis.max) - height - Style.margins, 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: toolTip.timestamp.toLocaleString(Qt.locale(), Locale.ShortFormat) + font: Style.smallFont + } + + Label { + property double value: toolTip.entry ? Math.max(0, -toolTip.entry.production) : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: qsTr("Total production: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") + font: Style.extraSmallFont + } + + RowLayout { + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.red + } + + Label { + // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings + Component.onCompleted: lowerSeries = selfConsumptionSeries.lowerSeries + property XYSeries lowerSeries: null + + property double value: toolTip.entry ? Math.max(0, toolTip.entry.consumption) : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: qsTr("Consumed: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") + font: Style.extraSmallFont + } + } + RowLayout { + visible: root.batteries.count > 0 + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.orange + } + + Label { + // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings + Component.onCompleted: lowerSeries = storageSeries.lowerSeries + property XYSeries lowerSeries: null + + property double value: toolTip.entry ? Math.max(0, toolTip.entry.storage) : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: qsTr("To battery: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") + font: Style.extraSmallFont + } + } + RowLayout { + Rectangle { + width: Style.extraSmallFont.pixelSize + height: width + color: Style.green + } + + Label { + // Workaround for Qt bug that lowerSeries is non-notifyable and throws warnings + Component.onCompleted: lowerSeries = acquisitionSeries.lowerSeries + property XYSeries lowerSeries: null + + property double value: toolTip.entry ? Math.max(0, -toolTip.entry.acquisition) : 0 + property bool translate: value >= 1000 + property double translatedValue: value / (translate ? 1000 : 1) + text: qsTr("To grid: %1 %2").arg(translatedValue.toFixed(2)).arg(translate ? "kW" : "W") + font: Style.extraSmallFont + } + } + } + } + } + } } + + } diff --git a/nymea-app/ui/mainviews/energy/StatsBase.qml b/nymea-app/ui/mainviews/energy/StatsBase.qml index df1998ce..448cde24 100644 --- a/nymea-app/ui/mainviews/energy/StatsBase.qml +++ b/nymea-app/ui/mainviews/energy/StatsBase.qml @@ -5,10 +5,10 @@ Item { id: root property int minutesCount: 9 - property int hoursCount: 11 - property int daysCount: 6 - property int weeksCount: 12 - property int monthsCount: 11 + property int hoursCount: 10 + property int daysCount: 7 + property int weeksCount: 10 + property int monthsCount: 6 property int yearsCount: 5 property var configs: ({ @@ -17,58 +17,61 @@ Item { startTime: minutesStart, sampleRate: EnergyLogs.SampleRate1Min, toLabel: minuteLabel, - toLongLabel: minuteLongLabel + toLongLabel: minuteLongLabel, + toRangeLabel: minuteRangeLabel }, hours: { count: hoursCount, startTime: hoursStart, sampleRate: EnergyLogs.SampleRate1Hour, toLabel: hourLabel, - toLongLabel: hourLongLabel + toLongLabel: hourLongLabel, + toRangeLabel: hourRangeLabel }, days: { count: daysCount, startTime: daysStart, sampleRate: EnergyLogs.SampleRate1Day, toLabel: dayLabel, - toLongLabel: dayLongLabel + toLongLabel: dayLongLabel, + toRangeLabel: dayRangeLabel }, weeks: { count: weeksCount, startTime: weeksStart, sampleRate: EnergyLogs.SampleRate1Week, toLabel: weekLabel, - toLongLabel: weekLongLabel + toLongLabel: weekLongLabel, + toRangeLabel: weekRangeLabel }, months: { count: monthsCount, startTime: monthsStart, sampleRate: EnergyLogs.SampleRate1Month, toLabel: monthLabel, - toLongLabel: monthLongLabel + toLongLabel: monthLongLabel, + toRangeLabel: monthRangeLabel }, years: { count: yearsCount, startTime: yearStart, sampleRate: EnergyLogs.SampleRate1Year, toLabel: yearLabel, - toLongLabel: yearLabel + toLongLabel: yearLongLabel, + toRangeLabel: yearRangeLabel } }) - function calculateSampleStart(sampleEnd, sampleRate, sampleCount) { - if (sampleCount === undefined) { - sampleCount = 1 - } - var sampleStart = new Date(sampleEnd) + function calculateTimestamp(baseTime, sampleRate, offset) { + var timestamp = new Date(baseTime); if (sampleRate === EnergyLogs.SampleRate1Month) { - sampleStart.setMonth(sampleEnd.getMonth() - sampleCount) + timestamp.setMonth(baseTime.getMonth() + offset) } else if (sampleRate === EnergyLogs.SampleRate1Year) { - sampleStart.setFullYear(sampleEnd.getFullYear() - sampleCount) + timestamp.setFullYear(baseTime.getFullYear() + offset) } else { - sampleStart.setTime(sampleEnd.getTime() - (sampleRate * 60000 * sampleCount)) + timestamp.setTime(baseTime.getTime() + (sampleRate * 60000 * offset)) } - return sampleStart + return timestamp; } function minutesStart() { @@ -82,6 +85,9 @@ Item { function minuteLongLabel(date) { return date.toLocaleString(Qt.locale(), Locale.ShortFormat) } + function minuteRangeLabel(date) { + return date.toLocaleString(Qt.locale(), Locale.ShortFormat) + " - " + new Date(date.getTime() + root.minutesCount * 60000).toLocaleString(Qt.locale(), Locale.ShortFormat) + } function hoursStart() { @@ -95,6 +101,9 @@ Item { function hourLongLabel(date) { return date.toLocaleString(Qt.locale(), Locale.ShortFormat) } + function hourRangeLabel(date) { + return date.toLocaleString(Qt.locale(), Locale.ShortFormat) + " - " + new Date(date.getTime() + root.hoursCount * 60 * 60000).toLocaleString(Qt.locale(), Locale.ShortFormat) + } function daysStart() { var d = new Date(); @@ -108,11 +117,14 @@ Item { function dayLongLabel(date) { return date.toLocaleDateString(Qt.locale(), Locale.ShortFormat) } + function dayRangeLabel(date) { + return date.toLocaleDateString(Qt.locale(), Locale.ShortFormat) + " - " + new Date(date.getTime() + root.daysCount * 24 * 60 * 60000).toLocaleDateString(Qt.locale(), Locale.ShortFormat) + } function weeksStart() { var d = new Date(); d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() - d.getDay() - weeksCount * 7); + d.setDate(d.getDate() - d.getDay() + 1 - (weeksCount - 1) * 7); return d } function weekLabel(date) { @@ -127,6 +139,11 @@ Item { endDate.setDate(endDate.getDate() + 6) return date.toLocaleDateString(Qt.locale(), Locale.ShortFormat) + " - " + endDate.toLocaleDateString(Qt.locale(), Locale.ShortFormat) } + function weekRangeLabel(date) { + var endDate = new Date(date) + endDate.setDate(endDate.getDate() + (7 * root.weeksCount)) + return date.toLocaleDateString(Qt.locale(), Locale.ShortFormat) + " - " + endDate.toLocaleDateString(Qt.locale(), Locale.ShortFormat) + } function monthsStart() { @@ -141,6 +158,11 @@ Item { function monthLongLabel(date) { return date.toLocaleString(Qt.locale(), "MMMM yyyy") } + function monthRangeLabel(date) { + var endDate = new Date(date); + endDate.setMonth(date.getMonth() + monthsCount - 1) + return date.toLocaleString(Qt.locale(), "MMMM yyyy") + " - " + endDate.toLocaleString(Qt.locale(), "MMMM yyyy") + } function yearStart() { var d = new Date(); @@ -151,5 +173,11 @@ Item { function yearLabel(date) { return date.toLocaleString(Qt.locale(), "yyyy") } + function yearLongLabel(date) { + return date.toLocaleString(Qt.locale(), "yyyy") + } + function yearRangeLabel(date) { + return "" + } }