import QtQuick 2.12 import QtQuick.Controls 2.12 import QtQuick.Layouts 1.12 import qt.koriolan.shipheart 1.0 import "responsive.js" as Responsive // ============================================================================ // 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 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 pour le // graphique de valeur (Canvas plutôt que QtCharts, ligne continue plutôt // qu'escalier, pas de zoom tactile). // ============================================================================ Shipheart_page { id: page_detail_batterie property int idElement: 0 property string nomElement: "" property int periodeIndex: 1 // 24 heures par défaut 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", "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 property real valeurMoyenne: 0 property double tempsMin: 0 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() { if (modeEnergie) dataProvider.get_energie("batteries", idElement, periodeIndex); else dataProvider.get_mesures("batteries", idElement, periodeIndex, variablesCodes[variableIndex]); } function formater_date(timestampMs) { return Qt.formatDateTime(new Date(timestampMs), "dd/MM HH:mm"); } Connections { target: dataProvider function onMesuresRecues(type, id, periode, variable, donnees) { if (id !== idElement || periode !== periodeIndex || variable !== variablesCodes[variableIndex]) return; pointsCourbe = donnees.points; uniteAffichage = donnees.unite; precisionAffichage = donnees.precision; valeurActuelle = donnees.valeur_actuelle; if (donnees.points.length > 0) { valeurMin = donnees.min.v; tempsMin = donnees.min.t; valeurMax = donnees.max.v; tempsMax = donnees.max.t; valeurMoyenne = donnees.moyenne; } 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) { console.log("DataProvider erreur (détail batterie) : " + message); } } Component.onCompleted: charger_courbe() Timer { interval: 5000 running: true repeat: true onTriggered: charger_courbe() } ColumnLayout { anchors.fill: parent anchors.margins: 20 spacing: 15 Text { text: nomElement font.pixelSize: Responsive.police(22, page_detail_batterie.width) font.bold: true color: couleur_gris_pale } // Sélecteur de variable (8 valeurs simples + énergie) - 4 colonnes // normalement, 2 si écran étroit : GridLayout { Layout.fillWidth: true columns: Responsive.colonnesSelonLargeur(page_detail_batterie.width, variablesLibelles.length) columnSpacing: 8 rowSpacing: 8 Repeater { model: variablesLibelles delegate: Rectangle { Layout.fillWidth: true height: Math.max(34, Responsive.HAUTEUR_MIN_TACTILE) radius: 8 color: (index === variableIndex) ? shipheart_couleur_fond_bouton_actif : shipheart_couleur_fond_bouton Text { anchors.centerIn: parent text: modelData color: couleur_blanc_casse font.pixelSize: Responsive.police(12, page_detail_batterie.width) font.bold: (index === variableIndex) } MouseArea { anchors.fill: parent onClicked: { variableIndex = index; charger_courbe(); } } } } } RowLayout { Layout.fillWidth: true spacing: 20 opacity: dataProvider.connecte ? 1.0 : 0.45 RowLayout { Layout.alignment: Qt.AlignVCenter spacing: 4 Text { text: modeEnergie ? (totalWh >= 1000 ? (totalWh / 1000).toFixed(2) : totalWh.toFixed(1)) : valeurActuelle.toFixed(precisionAffichage) font.pixelSize: Responsive.police(64, page_detail_batterie.width) font.bold: true color: couleur_blanc_casse } Text { text: modeEnergie ? (totalWh >= 1000 ? "kWh" : "Wh") : uniteAffichage font.pixelSize: Responsive.police(20, page_detail_batterie.width) color: couleur_gris_pale Layout.alignment: Qt.AlignTop } } Item { Layout.fillWidth: true } ColumnLayout { visible: !modeEnergie spacing: 10 RowLayout { Text { text: qsTr("Max"); color: couleur_gris_pale; font.pixelSize: Responsive.police(13, page_detail_batterie.width); Layout.preferredWidth: 90 } Text { text: valeurMax.toFixed(precisionAffichage) + " " + uniteAffichage; color: couleur_blanc_casse; font.pixelSize: Responsive.police(13, page_detail_batterie.width); Layout.preferredWidth: 100 } Text { text: "[" + formater_date(tempsMax) + "]"; color: couleur_gris_moyen; font.pixelSize: Responsive.police(13, page_detail_batterie.width) } } RowLayout { Text { text: qsTr("Moyen"); color: couleur_gris_pale; font.pixelSize: Responsive.police(13, page_detail_batterie.width); Layout.preferredWidth: 90 } Text { text: valeurMoyenne.toFixed(precisionAffichage) + " " + uniteAffichage; color: couleur_blanc_casse; font.pixelSize: Responsive.police(13, page_detail_batterie.width); Layout.preferredWidth: 100 } } RowLayout { Text { text: qsTr("Min"); color: couleur_gris_pale; font.pixelSize: Responsive.police(13, page_detail_batterie.width); Layout.preferredWidth: 90 } Text { text: valeurMin.toFixed(precisionAffichage) + " " + uniteAffichage; color: couleur_blanc_casse; font.pixelSize: Responsive.police(13, page_detail_batterie.width); Layout.preferredWidth: 100 } Text { text: "[" + formater_date(tempsMin) + "]"; color: couleur_gris_moyen; font.pixelSize: Responsive.police(13, page_detail_batterie.width) } } RowLayout { Text { text: qsTr("Delta (Max - Min)"); color: couleur_gris_pale; font.pixelSize: Responsive.police(13, page_detail_batterie.width); Layout.preferredWidth: 160 } Text { text: (valeurMax - valeurMin).toFixed(precisionAffichage + 1) + " " + uniteAffichage; color: couleur_blanc_casse; font.pixelSize: Responsive.police(13, page_detail_batterie.width) } } } Text { visible: modeEnergie text: qsTr("Consommation totale sur la période - détail par circuit ci-dessous") color: couleur_gris_pale font.pixelSize: Responsive.police(13, page_detail_batterie.width) } } RowLayout { Layout.fillWidth: true Layout.fillHeight: true spacing: 15 opacity: dataProvider.connecte ? 1.0 : 0.45 // --- Mode "variable simple" : graphique de valeur habituel --- Canvas { id: graphiqueVariable Layout.fillWidth: true Layout.fillHeight: true visible: !modeEnergie onPaint: { var ctx = getContext("2d"); ctx.reset(); ctx.clearRect(0, 0, width, height); if (pointsCourbe.length < 2) { 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 tMin = pointsCourbe[0].t; var tMax = pointsCourbe[pointsCourbe.length - 1].t; var padding = (valeurMax - valeurMin) * 0.1 || 1; var yMin = valeurMin - padding; var yMax = valeurMax + padding; function px(t) { return marge + ((t - tMin) / (tMax - tMin)) * largeurUtile; } function py(v) { return marge + hauteurUtile - ((v - yMin) / (yMax - yMin)) * hauteurUtile; } ctx.strokeStyle = "#333333"; ctx.fillStyle = couleur_gris_pale; ctx.font = "11px sans-serif"; var nbLignesY = 5; for (var i = 0; i <= nbLignesY; i++) { var v = yMin + (i / nbLignesY) * (yMax - yMin); var y = py(v); ctx.beginPath(); ctx.moveTo(marge, y); ctx.lineTo(width - marge, y); ctx.stroke(); ctx.fillText(v.toFixed(precisionAffichage), 2, y + 4); } // 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; ctx.lineWidth = 2; ctx.beginPath(); for (var k = 0; k < pointsCourbe.length; k++) { var xx = px(pointsCourbe[k].t); var yy = py(pointsCourbe[k].v); if (k === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy); } ctx.stroke(); ctx.fillStyle = "lightgreen"; ctx.beginPath(); ctx.arc(px(tempsMin), py(valeurMin), 4, 0, 2 * Math.PI); ctx.fill(); ctx.beginPath(); ctx.arc(px(tempsMax), py(valeurMax), 4, 0, 2 * Math.PI); ctx.fill(); } } // --- 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: Responsive.police(13, page_detail_batterie.width) Layout.preferredWidth: parent.width * 0.4 elide: Text.ElideRight } Text { text: modelData.energie_wh.toFixed(2) + " Wh" color: couleur_blanc_casse font.pixelSize: Responsive.police(13, page_detail_batterie.width) Layout.preferredWidth: parent.width * 0.3 } Text { text: modelData.pourcentage.toFixed(1) + " %" color: couleur_gris_pale font.pixelSize: Responsive.police(13, page_detail_batterie.width) Layout.fillWidth: true } } } } } ColumnLayout { spacing: 8 Repeater { model: periodesLibelles delegate: Rectangle { Layout.preferredWidth: 140 Layout.preferredHeight: Math.max(40, Responsive.HAUTEUR_MIN_TACTILE) radius: 8 color: (index === periodeIndex) ? shipheart_couleur_fond_bouton_actif : shipheart_couleur_fond_bouton Text { anchors.centerIn: parent text: modelData color: couleur_blanc_casse font.pixelSize: Responsive.police(14, page_detail_batterie.width) font.bold: (index === periodeIndex) } MouseArea { anchors.fill: parent onClicked: { periodeIndex = index; charger_courbe(); } } } } } } // Bouton retour : Rectangle { Layout.fillWidth: true Layout.preferredHeight: Math.max(44, Responsive.HAUTEUR_MIN_TACTILE) radius: 8 color: shipheart_couleur_fond_bouton Text { anchors.centerIn: parent text: "↩" font.pixelSize: Responsive.police(20, page_detail_batterie.width) color: couleur_blanc_casse } MouseArea { anchors.fill: parent onClicked: stackView.pop() } } } }