From 4d3f35d6d0392ec1b4e526c8938fe23090960201 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 8 Sep 2026 11:41:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C3=A9nergie):=20les=20charges=20d'un=20m?= =?UTF-8?q?=C3=AAme=20domaine=20se=20nuancent=20=E2=80=94=20et=20une=20tro?= =?UTF-8?q?isi=C3=A8me=20le=20fait=20seule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La V2C et la borne simulée, toutes deux en `ev`, recevaient exactement le même violet : indiscernables dans l'empilement, et la légende répétait la même pastille. Vu à l'écran dès qu'une seconde borne est apparue. `EtmTokens.nuance` décline sur le RANG dans le domaine, donc une troisième borne prend sa teinte sans qu'on déclare quoi que ce soit. Le décalage porte sur la LUMINOSITÉ, pas sur la teinte : glisser la teinte finirait par heurter le domaine voisin, l'éclaircir garde la famille lisible. ⚠️ La série est bornée à quatre, et c'est explicite plutôt que subi : au-delà, l'écart ne se voit plus, et prétendre distinguer huit nuances d'un même violet serait une promesse que l'œil ne tient pas. Le rang 3 et le rang 9 rendent la même couleur — le signe qu'il faut alors autre chose que la couleur pour identifier, et c'est un choix d'écran, pas un réglage de palette. Un test le fige. La couleur est calculée à UN endroit, partagé par le tracé et la légende : deux sources de couleur, c'est une légende qui finit par désigner la mauvaise bande. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- lib/screens/energy_screen.dart | 27 +++++++++++++++-- lib/theme/etm_tokens.dart | 23 ++++++++++++++ test/nuance_domaine_test.dart | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 test/nuance_domaine_test.dart diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index e5c2305..7bcdc13 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -351,7 +351,7 @@ class _EnergyScreenState extends State { const _LegendDot(EtmTokens.brand, 'Reste du foyer'), for (final s in _conso.series) _LegendDot( - EtmTokens.domaineOf(context, s.domaine) ?? + _couleursSeries(context)[s.thingId] ?? EtmTokens.mutedOf(context), s.label), ], @@ -611,6 +611,27 @@ class _EnergyScreenState extends State { /// le reste devient **négatif**. On ne le rabote pas à zéro : un reste négatif est la /// preuve d'un périmètre faux, et l'écraser effacerait la seule chose qui le dise. /// « Qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » — exactement ça. + /// La couleur de chaque série, **rang dans son domaine compris**. + /// + /// Calculée à un seul endroit pour que la légende et le tracé ne puissent pas diverger : + /// deux sources de couleur, c'est une légende qui finit par désigner la mauvaise bande. + Map _couleursSeries(BuildContext context) { + final rangs = {}; + final out = {}; + for (final s in _conso.series) { + final base = EtmTokens.domaineOf(context, s.domaine); + if (base == null) { + out[s.thingId] = EtmTokens.mutedOf(context); + continue; + } + final cle = s.domaine ?? ''; + final rang = rangs[cle] ?? 0; + rangs[cle] = rang + 1; + out[s.thingId] = EtmTokens.nuance(base, rang); + } + return out; + } + Widget _buildConsumerChart() { if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); if (_conso.estVide) return const SizedBox.shrink(); @@ -651,6 +672,7 @@ class _EnergyScreenState extends State { final resteNegatif = restes.any((v) => v < -1); final alignees = series; + final couleurs = _couleursSeries(context); final bandes = <({String label, Color couleur, List valeurs})>[ ( label: 'Reste du foyer', @@ -660,8 +682,7 @@ class _EnergyScreenState extends State { for (final s in alignees) ( label: s.label, - couleur: EtmTokens.domaineOf(context, s.domaine) ?? - EtmTokens.mutedOf(context), + couleur: couleurs[s.thingId] ?? EtmTokens.mutedOf(context), valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i) ?? 0.0] ), ]; diff --git a/lib/theme/etm_tokens.dart b/lib/theme/etm_tokens.dart index 1a63fa1..b6bc164 100644 --- a/lib/theme/etm_tokens.dart +++ b/lib/theme/etm_tokens.dart @@ -108,6 +108,29 @@ class EtmTokens { static Color ecsOf(BuildContext c) => _isDark(c) ? ecsDark : ecs; static Color evOf(BuildContext c) => _isDark(c) ? evDark : ev; + /// **Décliner la couleur d'un domaine quand il porte plusieurs charges.** + /// + /// Deux bornes dans le domaine `ev` recevaient exactement le même violet : indiscernables + /// dans l'empilement, et la légende répétait la même pastille deux fois. Vu à l'écran le + /// 2026-09-08, dès qu'une seconde borne est apparue. + /// + /// La nuance se calcule sur le RANG dans le domaine, donc une troisième borne prend sa + /// teinte toute seule — rien à déclarer. On joue sur la **luminosité** en HSL et pas sur + /// la teinte : glisser la teinte finirait par heurter le domaine voisin, alors que + /// l'éclaircir garde la famille lisible. + /// + /// ⚠️ **La série est bornée à quatre.** Au-delà, l'écart de luminosité devient trop fin + /// pour être vu, et prétendre distinguer huit nuances d'un même violet serait une + /// promesse que l'œil ne tient pas. À partir de la cinquième charge d'un même domaine, la + /// couleur cesse d'identifier — il faudra autre chose (étiquette au survol, séparation + /// des domaines), et c'est un vrai choix d'écran, pas un réglage de palette. + static Color nuance(Color base, int rang) { + if (rang <= 0) return base; + final h = HSLColor.fromColor(base); + final pas = 0.11 * (rang.clamp(0, 3)); + return h.withLightness((h.lightness + pas).clamp(0.0, 0.85)).toColor(); + } + /// La couleur d'un domaine de charge, ou `null` s'il n'en a pas. /// /// `null` n'est pas un défaut à combler : une charge sans domaine déclaré n'a pas de diff --git a/test/nuance_domaine_test.dart b/test/nuance_domaine_test.dart new file mode 100644 index 0000000..d272cd0 --- /dev/null +++ b/test/nuance_domaine_test.dart @@ -0,0 +1,55 @@ +/// **Deux charges d'un même domaine ne peuvent pas porter la même couleur.** +/// +/// Vu à l'écran le 2026-09-08 : la V2C et la borne simulée, toutes deux en `ev`, recevaient +/// exactement le même violet — indiscernables dans l'empilement, et la légende répétait la +/// même pastille. La nuance se calcule sur le RANG dans le domaine, donc une troisième +/// borne prend sa teinte sans qu'on déclare quoi que ce soit. +library; + +import 'package:etm_powersync_app/theme/etm_tokens.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const base = EtmTokens.ev; + + test('le rang 0 ne touche pas à la couleur du domaine', () { + expect(EtmTokens.nuance(base, 0), base); + }); + + test('trois charges d\'un même domaine donnent trois couleurs DISTINCTES', () { + final c = [for (int i = 0; i < 3; i++) EtmTokens.nuance(base, i)]; + expect(c.toSet(), hasLength(3), + reason: 'une troisième borne doit se distinguer sans rien déclarer'); + }); + + test('la luminosité monte, la teinte ne bouge pas', () { + // Glisser la teinte finirait par heurter le domaine voisin ; l'éclaircir garde la + // famille lisible. + final h0 = HSLColor.fromColor(EtmTokens.nuance(base, 0)); + final h2 = HSLColor.fromColor(EtmTokens.nuance(base, 2)); + expect(h2.lightness, greaterThan(h0.lightness)); + expect((h2.hue - h0.hue).abs(), lessThan(1.0)); + }); + + test('la série est BORNÉE, et ne prétend pas distinguer l\'indistinguable', () { + // Au-delà de quatre, l'écart de luminosité ne se voit plus. Le plafond est explicite + // plutôt que subi : le rang 3 et le rang 9 rendent la même couleur, et c'est le signe + // qu'il faut autre chose que la couleur pour identifier — pas une nuance de plus. + expect(EtmTokens.nuance(base, 9), EtmTokens.nuance(base, 3)); + }); + + testWidgets('un domaine inconnu n\'a pas de couleur — et pas une couleur par défaut', + (tester) async { + // Une charge sans domaine déclaré n'est pas classée : lui donner une couleur de + // domaine la ferait passer pour classée. + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (c) { + ctx = c; + return const SizedBox(); + }))); + expect(EtmTokens.domaineOf(ctx, null), isNull); + expect(EtmTokens.domaineOf(ctx, 'inconnu'), isNull); + expect(EtmTokens.domaineOf(ctx, 'ev'), isNotNull); + }); +}