diff --git a/dataprovider.cpp b/dataprovider.cpp index 577a5d1..f0fa058 100644 --- a/dataprovider.cpp +++ b/dataprovider.cpp @@ -159,3 +159,41 @@ void DataProvider::get_mesures(const QString &type, int id, int periode, const Q emit mesuresRecues(type, id, periode, variable, obj); }); } + +void DataProvider::get_energie(const QString &type, int id, int periode) +{ + if (m_replyEnergieEnCours) m_replyEnergieEnCours->abort(); + + QUrl url(m_baseUrl + "/energie"); + QUrlQuery query; + query.addQueryItem("type", type); + query.addQueryItem("id", QString::number(id)); + query.addQueryItem("periode", QString::number(periode)); + url.setQuery(query); + + QNetworkRequest request(url); + request.setTransferTimeout(8000); + + auto reply = m_manager.get(request); + m_replyEnergieEnCours = reply; + + connect(reply, &QNetworkReply::finished, this, [this, reply, type, id, periode]() { + reply->deleteLater(); + if (m_replyEnergieEnCours == reply) m_replyEnergieEnCours = nullptr; + if (reply->error() == QNetworkReply::OperationCanceledError) return; + if (reply->error() != QNetworkReply::NoError) { + signalerEchec(); + emit erreur(reply->errorString()); + return; + } + const auto doc = QJsonDocument::fromJson(reply->readAll()); + const auto obj = doc.object(); + if (obj["status"].toString() != "ok") { + signalerEchec(); + emit erreur(obj["message"].toString()); + return; + } + signalerSucces(); + emit energieRecue(type, id, periode, obj); + }); +} diff --git a/dataprovider.h b/dataprovider.h index 5b086b7..c7225ef 100644 --- a/dataprovider.h +++ b/dataprovider.h @@ -50,6 +50,11 @@ public: // client), ignoré pour capteurs/reservoirs (variable fixe côté serveur). Q_INVOKABLE void get_mesures(const QString &type, int id, int periode, const QString &variable = QString()); + // Consommation d'énergie (Phase 4, batteries uniquement pour l'instant) - + // GET /energie. Endpoint séparé de get_mesures() : réponse de forme très + // différente (barres + comparatif par circuit, pas une simple courbe). + Q_INVOKABLE void get_energie(const QString &type, int id, int periode); + bool connecte() const { return m_connecte; } bool aDejaEteConnecte() const { return m_derniereConnexionReussie.isValid(); } QDateTime derniereConnexionReussie() const { return m_derniereConnexionReussie; } @@ -59,6 +64,7 @@ signals: void elementsRecus(const QString &type, int famille, const QJsonArray &elements); void circuitModifie(int id, bool succes); void mesuresRecues(const QString &type, int id, int periode, const QString &variable, const QJsonObject &donnees); + void energieRecue(const QString &type, int id, int periode, const QJsonObject &donnees); void erreur(const QString &message); void etatConnexionChange(); @@ -74,4 +80,5 @@ private: QNetworkReply *m_replyFamillesEnCours = nullptr; QNetworkReply *m_replyElementsEnCours = nullptr; QNetworkReply *m_replyMesuresEnCours = nullptr; + QNetworkReply *m_replyEnergieEnCours = nullptr; }; diff --git a/dataprovider_OLD.cpp b/dataprovider_OLD.cpp deleted file mode 100644 index 7a93f5e..0000000 --- a/dataprovider_OLD.cpp +++ /dev/null @@ -1,139 +0,0 @@ -#include "dataprovider.h" -#include -#include -#include -#include -#include -#include - -DataProvider::DataProvider(QObject *parent) : QObject(parent) -{ -} - -void DataProvider::signalerSucces() -{ - m_connecte = true; - m_derniereConnexionReussie = QDateTime::currentDateTime(); - emit etatConnexionChange(); -} - -void DataProvider::signalerEchec() -{ - // Ne touche JAMAIS m_derniereConnexionReussie ici : on veut garder la - // trace du dernier succès réel, pas la remplacer par l'instant de l'échec. - if (m_connecte) { - m_connecte = false; - emit etatConnexionChange(); - } -} - -void DataProvider::get_familles(const QString &type) -{ - if (m_replyFamillesEnCours) { - m_replyFamillesEnCours->abort(); // Requête précédente encore en attente : remplacée par celle-ci - } - - QUrl url(m_baseUrl + "/elements/familles"); - QUrlQuery query; - query.addQueryItem("type", type); - url.setQuery(query); - - QNetworkRequest request(url); - // Sans délai explicite, une requête vers un CM5 devenu injoignable peut - // rester en attente indéfiniment côté navigateur (ni succès ni échec) - - // 'finished' ne se déclencherait alors jamais, empêchant toute détection - // de perte de connexion. - request.setTransferTimeout(TIME_OUT_TRANSFER_DATA); - - auto reply = m_manager.get(request); - m_replyFamillesEnCours = reply; - - connect(reply, &QNetworkReply::finished, this, [this, reply, type]() { - reply->deleteLater(); - if (m_replyFamillesEnCours == reply) m_replyFamillesEnCours = nullptr; - - if (reply->error() == QNetworkReply::OperationCanceledError) { - return; // Annulée volontairement (remplacée par une requête plus récente) - pas un vrai échec réseau - } - if (reply->error() != QNetworkReply::NoError) { - signalerEchec(); - emit erreur(reply->errorString()); - return; - } - const auto doc = QJsonDocument::fromJson(reply->readAll()); - const auto obj = doc.object(); - if (obj["status"].toString() != "ok") { - signalerEchec(); - emit erreur(obj["message"].toString()); - return; - } - signalerSucces(); - emit famillesRecues(type, obj["familles"].toArray()); - }); -} - -void DataProvider::get_elements(const QString &type, int famille) -{ - if (m_replyElementsEnCours) { - m_replyElementsEnCours->abort(); - } - - QUrl url(m_baseUrl + "/elements"); - QUrlQuery query; - query.addQueryItem("type", type); - query.addQueryItem("famille", QString::number(famille)); - url.setQuery(query); - - QNetworkRequest request(url); - request.setTransferTimeout(TIME_OUT_TRANSFER_DATA); - - auto reply = m_manager.get(request); - m_replyElementsEnCours = reply; - - connect(reply, &QNetworkReply::finished, this, [this, reply, type, famille]() { - reply->deleteLater(); - if (m_replyElementsEnCours == reply) m_replyElementsEnCours = nullptr; - - if (reply->error() == QNetworkReply::OperationCanceledError) { - return; - } - if (reply->error() != QNetworkReply::NoError) { - signalerEchec(); - emit erreur(reply->errorString()); - return; - } - const auto doc = QJsonDocument::fromJson(reply->readAll()); - const auto obj = doc.object(); - if (obj["status"].toString() != "ok") { - signalerEchec(); - emit erreur(obj["message"].toString()); - return; - } - signalerSucces(); - emit elementsRecus(type, famille, obj["elements"].toArray()); - }); -} - -void DataProvider::set_circuit(int id, int consigne) -{ - QUrl url(m_baseUrl + "/elements/circuit/" + QString::number(id)); - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setTransferTimeout(TIME_OUT_TRANSFER_DATA); - - QJsonObject body; - body["consigne"] = consigne; - const QByteArray data = QJsonDocument(body).toJson(); - - auto reply = m_manager.sendCustomRequest(request, "PATCH", data); - connect(reply, &QNetworkReply::finished, this, [this, reply, id]() { - reply->deleteLater(); - const bool succes = (reply->error() == QNetworkReply::NoError); - if (succes) signalerSucces(); - else { - signalerEchec(); - emit erreur(reply->errorString()); - } - emit circuitModifie(id, succes); - }); -} diff --git a/dataprovider_OLD.h b/dataprovider_OLD.h deleted file mode 100644 index 73125cc..0000000 --- a/dataprovider_OLD.h +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once -#include -#include -#include -#include - -class QNetworkReply; - -// ============================================================================ -// DataProvider - couche d'accès aux données du client WASM (cas 2/3). -// ---------------------------------------------------------------------------- -// Remplace, pour les domaines déjà portés, les appels bdd.* directs du cas 1 -// (qui reste inchangé, cf décision §21.1 de la synthèse). Toute la logique -// HTTP/JSON est encapsulée ici - le QML ne voit que des signaux avec des -// tableaux déjà prêts à l'emploi. -// -// Portée actuelle : les 4 domaines (circuit, batterie, capteur, reservoir) -// en lecture, écriture (consigne/mode auto) sur circuit uniquement. -// -// Suit également l'état de connexion (succès/échec de chaque requête) pour -// permettre à l'UI d'afficher un état "hors ligne" explicite plutôt que de -// laisser les listes silencieusement vides ou figées (cf synthèse, gestion -// du hors-ligne). -// ============================================================================ - -#define TIME_OUT_TRANSFER_DATA 5000 // En ms - - -class DataProvider : public QObject -{ - Q_OBJECT - - Q_PROPERTY(bool connecte READ connecte NOTIFY etatConnexionChange) - Q_PROPERTY(bool aDejaEteConnecte READ aDejaEteConnecte NOTIFY etatConnexionChange) - Q_PROPERTY(QDateTime derniereConnexionReussie READ derniereConnexionReussie NOTIFY etatConnexionChange) - -public: - explicit DataProvider(QObject *parent = nullptr); - - // TODO : rendre l'adresse de base configurable (IP locale du CM5 en cas 2, - // domaine VPS en cas 3 - cf QR code d'accès, §"Architecture cible"/Cas 2-3 - // de la synthèse) - en dur sur le relais VPS pour cette étape. - Q_INVOKABLE void get_familles(const QString &type); - Q_INVOKABLE void get_elements(const QString &type, int famille); - - // Écriture rapide sur un circuit (bouton d'accueil) - PATCH /elements/circuit/. - Q_INVOKABLE void set_circuit(int id, int consigne); - - bool connecte() const { return m_connecte; } - bool aDejaEteConnecte() const { return m_derniereConnexionReussie.isValid(); } - QDateTime derniereConnexionReussie() const { return m_derniereConnexionReussie; } - -signals: - // Le type (et, pour les éléments, la famille) sont désormais inclus dans - // chaque signal - indispensable avec plusieurs domaines actifs en - // parallèle, pour que le QML sache à quelle réponse il a affaire. - void famillesRecues(const QString &type, const QJsonArray &familles); - void elementsRecus(const QString &type, int famille, const QJsonArray &elements); - void circuitModifie(int id, bool succes); - void erreur(const QString &message); - void etatConnexionChange(); - -private: - void signalerSucces(); - void signalerEchec(); - - QNetworkAccessManager m_manager; - QString m_baseUrl = QStringLiteral("https://api.ship2net.fr"); - bool m_connecte = false; - QDateTime m_derniereConnexionReussie; // invalide (isValid()==false) tant qu'aucun succès n'a jamais eu lieu - - // Requête en cours par type d'appel (familles/éléments) - permet d'annuler - // une requête encore en vol avant d'en émettre une nouvelle, pour éviter - // qu'une réponse "en retard" (envoyée pendant une coupure, résolue après - // qu'une requête plus récente ait déjà réussi) ne fasse clignoter l'état - // de connexion après une reconnexion. - QNetworkReply *m_replyFamillesEnCours = nullptr; - QNetworkReply *m_replyElementsEnCours = nullptr; -}; diff --git a/qml/detail_batterie.qml b/qml/detail_batterie.qml index a08b361..4931004 100644 --- a/qml/detail_batterie.qml +++ b/qml/detail_batterie.qml @@ -8,13 +8,16 @@ import qt.koriolan.shipheart 1.0 // detail_batterie.qml - page de détail d'une batterie (valeur + historique). // ---------------------------------------------------------------------------- // Poussée sur le StackView depuis home.qml (clic court sur une batterie). -// Domaine batteries : sélecteur de variable (8 options), comme les circuits. -// Le vrai défaut natif est le comparatif d'énergie (id_variable=0), -// volontairement laissé de côté (Phase 4, énergie, cf synthèse) - "SOC" -// choisi comme vue par défaut à la place, sur décision explicite. +// Domaine batteries : sélecteur de variable (8 options simples + "Énergie"). +// "Énergie" (9e bouton) bascule vers une vue différente (barres + tableau +// comparatif par circuit) plutôt que le graphique de valeur habituel - cf +// synthèse §22, Phase 4. Version SIMPLIFIÉE par rapport au natif : pas +// d'intégrale de puissance pour rafraîchir la barre "en cours" (décision +// explicite, cf §22). // -// Mêmes simplifications volontaires que detail_capteur.qml (Canvas plutôt que -// QtCharts, ligne continue plutôt qu'escalier, pas de zoom tactile). +// Mêmes simplifications volontaires que detail_capteur.qml pour le +// graphique de valeur (Canvas plutôt que QtCharts, ligne continue plutôt +// qu'escalier, pas de zoom tactile). // ============================================================================ Shipheart_page { @@ -27,9 +30,11 @@ Shipheart_page { readonly property var periodesLibelles: [qsTr("1 heure"), qsTr("24 heures"), qsTr("7 jours"), qsTr("30 jours")] property int variableIndex: 0 // "soc" par défaut - readonly property var variablesCodes: ["soc", "tension", "temperature", "courant_differentiel", "courant_charge", "courant_decharge", "soh", "autonomie"] - readonly property var variablesLibelles: [qsTr("SOC"), qsTr("Tension"), qsTr("Température"), qsTr("I. différentiel"), qsTr("I. charge"), qsTr("I. décharge"), qsTr("SOH"), qsTr("Autonomie")] + readonly property var variablesCodes: ["soc", "tension", "temperature", "courant_differentiel", "courant_charge", "courant_decharge", "soh", "autonomie", "energie"] + readonly property var variablesLibelles: [qsTr("SOC"), qsTr("Tension"), qsTr("Température"), qsTr("I. différentiel"), qsTr("I. charge"), qsTr("I. décharge"), qsTr("SOH"), qsTr("Autonomie"), qsTr("Énergie")] + readonly property bool modeEnergie: variablesCodes[variableIndex] === "energie" + // --- État pour le mode "variable simple" (graphique de valeur) --- property var pointsCourbe: [] property real valeurMin: 0 property real valeurMax: 0 @@ -38,13 +43,18 @@ Shipheart_page { property double tempsMax: 0 property string uniteAffichage: "" property int precisionAffichage: 1 - property real valeurActuelle: 0 + // --- État pour le mode "énergie" (barres + comparatif) --- + property real totalWh: 0 + property var barresEnergie: [] + property var comparatifEnergie: [] + property DataProvider dataProvider: DataProvider {} function charger_courbe() { - dataProvider.get_mesures("batteries", idElement, periodeIndex, variablesCodes[variableIndex]); + if (modeEnergie) dataProvider.get_energie("batteries", idElement, periodeIndex); + else dataProvider.get_mesures("batteries", idElement, periodeIndex, variablesCodes[variableIndex]); } function formater_date(timestampMs) { @@ -68,7 +78,15 @@ Shipheart_page { tempsMax = donnees.max.t; valeurMoyenne = donnees.moyenne; } - graphique.requestPaint(); + graphiqueVariable.requestPaint(); + } + + function onEnergieRecue(type, id, periode, donnees) { + if (id !== idElement || periode !== periodeIndex || !modeEnergie) return; + totalWh = donnees.total_wh; + barresEnergie = donnees.barres; + comparatifEnergie = donnees.comparatif; + graphiqueBarres.requestPaint(); } function onErreur(message) { @@ -97,8 +115,7 @@ Shipheart_page { color: couleur_gris_pale } - // Sélecteur de variable (8 options - grille 4x2 plutôt qu'une seule - // ligne, pour rester lisible) : + // Sélecteur de variable (8 valeurs simples + énergie) : GridLayout { Layout.fillWidth: true columns: 4 @@ -141,13 +158,15 @@ Shipheart_page { Layout.alignment: Qt.AlignVCenter spacing: 4 Text { - text: valeurActuelle.toFixed(precisionAffichage) + text: modeEnergie + ? (totalWh >= 1000 ? (totalWh / 1000).toFixed(2) : totalWh.toFixed(1)) + : valeurActuelle.toFixed(precisionAffichage) font.pixelSize: 64 font.bold: true color: couleur_blanc_casse } Text { - text: uniteAffichage + text: modeEnergie ? (totalWh >= 1000 ? "kWh" : "Wh") : uniteAffichage font.pixelSize: 20 color: couleur_gris_pale Layout.alignment: Qt.AlignTop @@ -157,6 +176,7 @@ Shipheart_page { Item { Layout.fillWidth: true } ColumnLayout { + visible: !modeEnergie spacing: 10 RowLayout { Text { text: qsTr("Max"); color: couleur_gris_pale; Layout.preferredWidth: 90 } @@ -177,6 +197,13 @@ Shipheart_page { Text { text: (valeurMax - valeurMin).toFixed(precisionAffichage + 1) + " " + uniteAffichage; color: couleur_blanc_casse } } } + + Text { + visible: modeEnergie + text: qsTr("Consommation totale sur la période - détail par circuit ci-dessous") + color: couleur_gris_pale + font.pixelSize: 13 + } } RowLayout { @@ -185,10 +212,12 @@ Shipheart_page { spacing: 15 opacity: dataProvider.connecte ? 1.0 : 0.45 + // --- Mode "variable simple" : graphique de valeur habituel --- Canvas { - id: graphique + id: graphiqueVariable Layout.fillWidth: true Layout.fillHeight: true + visible: !modeEnergie onPaint: { var ctx = getContext("2d"); @@ -230,11 +259,43 @@ Shipheart_page { ctx.fillText(v.toFixed(precisionAffichage), 2, y + 4); } - var nbLabelsX = 6; - for (var j = 0; j <= nbLabelsX; j++) { - var t = tMin + (j / nbLabelsX) * (tMax - tMin); - var x = px(t); - ctx.fillText(Qt.formatDateTime(new Date(t), "HH:mm"), x - 18, height - 8); + // Quadrillage vertical ET étiquettes, ensemble, sur les mêmes + // paliers ronds - paliers de 5 min (1h), heure pleine (24h) ou + // minuit (7j/30j). Remplace l'ancien double affichage (quadrillage + // sans étiquette + étiquettes à des positions non alignées). + function prochain_palier_rond(instant, periodeIdx) { + var d = new Date(instant); + if (periodeIdx >= 2) { + d.setHours(0, 0, 0, 0); + if (d.getTime() < instant) d.setDate(d.getDate() + 1); + } else if (periodeIdx === 1) { + d.setMinutes(0, 0, 0); + if (d.getTime() < instant) d.setHours(d.getHours() + 1); + } else { + d.setSeconds(0, 0); + d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5); + if (d.getTime() < instant) d.setMinutes(d.getMinutes() + 5); + } + return d.getTime(); + } + var pasGrilleMs = (periodeIndex >= 2) ? 86400000 : (periodeIndex === 1) ? 3600000 : 5 * 60000; + var formatEtiquette = (periodeIndex >= 2) ? "dd/MM" : "HH:mm"; + ctx.strokeStyle = "#2a2a2a"; + ctx.fillStyle = couleur_gris_pale; + // Une étiquette sur 3 seulement pour 24h/30j (trop chargé sinon, + // 24-25 ou 30 barres) - à partir de la première, quitte à ce que les + // 1-2 dernières graduations restent sans étiquette : + var pasEtiquette = (periodeIndex === 1 || periodeIndex === 3) ? 3 : 1; + var indexGrille = 0; + for (var tGrille = prochain_palier_rond(tMin, periodeIndex); tGrille <= tMax; tGrille += pasGrilleMs, indexGrille++) { + var xGrille = px(tGrille); + ctx.beginPath(); + ctx.moveTo(xGrille, marge); + ctx.lineTo(xGrille, marge + hauteurUtile); + ctx.stroke(); + if (indexGrille % pasEtiquette === 0) { + ctx.fillText(Qt.formatDateTime(new Date(tGrille), formatEtiquette), xGrille - 18, height - 8); + } } ctx.strokeStyle = couleur_bleu_koriolan; @@ -258,6 +319,120 @@ Shipheart_page { } } + // --- Mode "énergie" : barres + tableau comparatif --- + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: modeEnergie + spacing: 10 + + Canvas { + id: graphiqueBarres + Layout.fillWidth: true + Layout.preferredHeight: parent.height * 0.45 + + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + ctx.clearRect(0, 0, width, height); + + if (barresEnergie.length === 0) { + ctx.fillStyle = couleur_rouge_transparent; + ctx.font = "14px sans-serif"; + ctx.fillText(qsTr("Pas assez de données sur cette période."), width / 2 - 110, height / 2); + return; + } + + var marge = 45; + var largeurUtile = width - 2 * marge; + var hauteurUtile = height - 2 * marge; + + var maxBarre = 0; + for (var i = 0; i < barresEnergie.length; i++) { + if (barresEnergie[i].valeur_wh > maxBarre) maxBarre = barresEnergie[i].valeur_wh; + } + if (maxBarre <= 0) maxBarre = 1; + + var largeurBarre = (largeurUtile / barresEnergie.length) * 0.7; + var pasBarre = largeurUtile / barresEnergie.length; + + ctx.strokeStyle = "#333333"; + ctx.fillStyle = couleur_gris_pale; + ctx.font = "10px sans-serif"; + var nbLignesY = 4; + for (var l = 0; l <= nbLignesY; l++) { + var v = (l / nbLignesY) * maxBarre; + var y = marge + hauteurUtile - (v / maxBarre) * hauteurUtile; + ctx.beginPath(); + ctx.moveTo(marge, y); + ctx.lineTo(width - marge, y); + ctx.stroke(); + ctx.fillText(v.toFixed(1), 2, y + 4); + } + + ctx.fillStyle = couleur_bleu_koriolan; + for (var k = 0; k < barresEnergie.length; k++) { + var hBarre = (barresEnergie[k].valeur_wh / maxBarre) * hauteurUtile; + var xBarre = marge + k * pasBarre + (pasBarre - largeurBarre) / 2; + var yBarre = marge + hauteurUtile - hBarre; + ctx.fillRect(xBarre, yBarre, largeurBarre, hBarre); + + if (k === 0 || k === barresEnergie.length - 1 || k === Math.floor(barresEnergie.length / 2)) { + ctx.fillStyle = couleur_gris_pale; + var dateDebutBarre = new Date(barresEnergie[k].debut); + var etiquette = (dateDebutBarre.getHours() === 0 && dateDebutBarre.getMinutes() === 0) + ? Qt.formatDateTime(dateDebutBarre, "dd/MM") + : Qt.formatDateTime(dateDebutBarre, "HH:mm"); + ctx.fillText(etiquette, xBarre - 15, height - 8); + ctx.fillStyle = couleur_bleu_koriolan; + } + } + } + } + + // Tableau comparatif par circuit : + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + border.color: "#333333" + border.width: 1 + radius: 6 + + ListView { + anchors.fill: parent + anchors.margins: 8 + clip: true + model: comparatifEnergie + spacing: 4 + + delegate: RowLayout { + width: ListView.view.width + + Text { + text: modelData.nom + color: (modelData.consigne > 0) ? couleur_blanc_casse : couleur_gris_moyen + font.pixelSize: 13 + Layout.preferredWidth: parent.width * 0.4 + elide: Text.ElideRight + } + Text { + text: modelData.energie_wh.toFixed(2) + " Wh" + color: couleur_blanc_casse + font.pixelSize: 13 + Layout.preferredWidth: parent.width * 0.3 + } + Text { + text: modelData.pourcentage.toFixed(1) + " %" + color: couleur_gris_pale + font.pixelSize: 13 + Layout.fillWidth: true + } + } + } + } + } + ColumnLayout { spacing: 8 Repeater { diff --git a/qml/detail_capteur.qml b/qml/detail_capteur.qml index 563d820..f6c2702 100644 --- a/qml/detail_capteur.qml +++ b/qml/detail_capteur.qml @@ -203,12 +203,43 @@ Shipheart_page { ctx.fillText(v.toFixed(precisionAffichage), 2, y + 4); } - // Étiquettes X : - var nbLabelsX = 6; - for (var j = 0; j <= nbLabelsX; j++) { - var t = tMin + (j / nbLabelsX) * (tMax - tMin); - var x = px(t); - ctx.fillText(Qt.formatDateTime(new Date(t), "HH:mm"), x - 18, height - 8); + // Quadrillage vertical ET étiquettes, ensemble, sur les mêmes + // paliers ronds - paliers de 5 min (1h), heure pleine (24h) ou + // minuit (7j/30j). Remplace l'ancien double affichage (quadrillage + // sans étiquette + étiquettes à des positions non alignées). + function prochain_palier_rond(instant, periodeIdx) { + var d = new Date(instant); + if (periodeIdx >= 2) { + d.setHours(0, 0, 0, 0); + if (d.getTime() < instant) d.setDate(d.getDate() + 1); + } else if (periodeIdx === 1) { + d.setMinutes(0, 0, 0); + if (d.getTime() < instant) d.setHours(d.getHours() + 1); + } else { + d.setSeconds(0, 0); + d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5); + if (d.getTime() < instant) d.setMinutes(d.getMinutes() + 5); + } + return d.getTime(); + } + var pasGrilleMs = (periodeIndex >= 2) ? 86400000 : (periodeIndex === 1) ? 3600000 : 5 * 60000; + var formatEtiquette = (periodeIndex >= 2) ? "dd/MM" : "HH:mm"; + ctx.strokeStyle = "#2a2a2a"; + ctx.fillStyle = couleur_gris_pale; + // Une étiquette sur 3 seulement pour 24h/30j (trop chargé sinon, + // 24-25 ou 30 barres) - à partir de la première, quitte à ce que les + // 1-2 dernières graduations restent sans étiquette : + var pasEtiquette = (periodeIndex === 1 || periodeIndex === 3) ? 3 : 1; + var indexGrille = 0; + for (var tGrille = prochain_palier_rond(tMin, periodeIndex); tGrille <= tMax; tGrille += pasGrilleMs, indexGrille++) { + var xGrille = px(tGrille); + ctx.beginPath(); + ctx.moveTo(xGrille, marge); + ctx.lineTo(xGrille, marge + hauteurUtile); + ctx.stroke(); + if (indexGrille % pasEtiquette === 0) { + ctx.fillText(Qt.formatDateTime(new Date(tGrille), formatEtiquette), xGrille - 18, height - 8); + } } // Ligne de la courbe : diff --git a/qml/detail_circuit.qml b/qml/detail_circuit.qml index 623daec..4c78bf1 100644 --- a/qml/detail_circuit.qml +++ b/qml/detail_circuit.qml @@ -227,11 +227,43 @@ Shipheart_page { ctx.fillText(v.toFixed(precisionAffichage), 2, y + 4); } - var nbLabelsX = 6; - for (var j = 0; j <= nbLabelsX; j++) { - var t = tMin + (j / nbLabelsX) * (tMax - tMin); - var x = px(t); - ctx.fillText(Qt.formatDateTime(new Date(t), "HH:mm"), x - 18, height - 8); + // Quadrillage vertical ET étiquettes, ensemble, sur les mêmes + // paliers ronds - paliers de 5 min (1h), heure pleine (24h) ou + // minuit (7j/30j). Remplace l'ancien double affichage (quadrillage + // sans étiquette + étiquettes à des positions non alignées). + function prochain_palier_rond(instant, periodeIdx) { + var d = new Date(instant); + if (periodeIdx >= 2) { + d.setHours(0, 0, 0, 0); + if (d.getTime() < instant) d.setDate(d.getDate() + 1); + } else if (periodeIdx === 1) { + d.setMinutes(0, 0, 0); + if (d.getTime() < instant) d.setHours(d.getHours() + 1); + } else { + d.setSeconds(0, 0); + d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5); + if (d.getTime() < instant) d.setMinutes(d.getMinutes() + 5); + } + return d.getTime(); + } + var pasGrilleMs = (periodeIndex >= 2) ? 86400000 : (periodeIndex === 1) ? 3600000 : 5 * 60000; + var formatEtiquette = (periodeIndex >= 2) ? "dd/MM" : "HH:mm"; + ctx.strokeStyle = "#2a2a2a"; + ctx.fillStyle = couleur_gris_pale; + // Une étiquette sur 3 seulement pour 24h/30j (trop chargé sinon, + // 24-25 ou 30 barres) - à partir de la première, quitte à ce que les + // 1-2 dernières graduations restent sans étiquette : + var pasEtiquette = (periodeIndex === 1 || periodeIndex === 3) ? 3 : 1; + var indexGrille = 0; + for (var tGrille = prochain_palier_rond(tMin, periodeIndex); tGrille <= tMax; tGrille += pasGrilleMs, indexGrille++) { + var xGrille = px(tGrille); + ctx.beginPath(); + ctx.moveTo(xGrille, marge); + ctx.lineTo(xGrille, marge + hauteurUtile); + ctx.stroke(); + if (indexGrille % pasEtiquette === 0) { + ctx.fillText(Qt.formatDateTime(new Date(tGrille), formatEtiquette), xGrille - 18, height - 8); + } } ctx.strokeStyle = couleur_bleu_koriolan; diff --git a/qml/detail_reservoir.qml b/qml/detail_reservoir.qml index 20a7c2d..146fbf9 100644 --- a/qml/detail_reservoir.qml +++ b/qml/detail_reservoir.qml @@ -194,12 +194,43 @@ Shipheart_page { ctx.fillText(v.toFixed(precisionAffichage), 2, y + 4); } - // Étiquettes X : - var nbLabelsX = 6; - for (var j = 0; j <= nbLabelsX; j++) { - var t = tMin + (j / nbLabelsX) * (tMax - tMin); - var x = px(t); - ctx.fillText(Qt.formatDateTime(new Date(t), "HH:mm"), x - 18, height - 8); + // Quadrillage vertical ET étiquettes, ensemble, sur les mêmes + // paliers ronds - paliers de 5 min (1h), heure pleine (24h) ou + // minuit (7j/30j). Remplace l'ancien double affichage (quadrillage + // sans étiquette + étiquettes à des positions non alignées). + function prochain_palier_rond(instant, periodeIdx) { + var d = new Date(instant); + if (periodeIdx >= 2) { + d.setHours(0, 0, 0, 0); + if (d.getTime() < instant) d.setDate(d.getDate() + 1); + } else if (periodeIdx === 1) { + d.setMinutes(0, 0, 0); + if (d.getTime() < instant) d.setHours(d.getHours() + 1); + } else { + d.setSeconds(0, 0); + d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5); + if (d.getTime() < instant) d.setMinutes(d.getMinutes() + 5); + } + return d.getTime(); + } + var pasGrilleMs = (periodeIndex >= 2) ? 86400000 : (periodeIndex === 1) ? 3600000 : 5 * 60000; + var formatEtiquette = (periodeIndex >= 2) ? "dd/MM" : "HH:mm"; + ctx.strokeStyle = "#2a2a2a"; + ctx.fillStyle = couleur_gris_pale; + // Une étiquette sur 3 seulement pour 24h/30j (trop chargé sinon, + // 24-25 ou 30 barres) - à partir de la première, quitte à ce que les + // 1-2 dernières graduations restent sans étiquette : + var pasEtiquette = (periodeIndex === 1 || periodeIndex === 3) ? 3 : 1; + var indexGrille = 0; + for (var tGrille = prochain_palier_rond(tMin, periodeIndex); tGrille <= tMax; tGrille += pasGrilleMs, indexGrille++) { + var xGrille = px(tGrille); + ctx.beginPath(); + ctx.moveTo(xGrille, marge); + ctx.lineTo(xGrille, marge + hauteurUtile); + ctx.stroke(); + if (indexGrille % pasEtiquette === 0) { + ctx.fillText(Qt.formatDateTime(new Date(tGrille), formatEtiquette), xGrille - 18, height - 8); + } } // Ligne de la courbe :