From 5ad222431d28ea5ec3fe5ca28f7e59081bc56ad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:58:31 +0000 Subject: [PATCH] Carte tendance : refonte du graphique selon les regles data-viz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Petits multiples empiles sur un axe des mois commun, palette validee par script, etiquetage selectif, survol et vue tableau. FORME. Trois mesures d'ordres de grandeur differents (qte/magasin, volumes, magasins) ne peuvent pas partager un cadre : il faudrait deux ou trois echelles verticales dont l'alignement serait arbitraire, ce qui inventerait des correlations absentes des donnees. Trois facettes, une echelle chacune, le meme axe horizontal. La facette PRINCIPALE est la qte/magasin. COULEUR. Slots 1-3 de la palette categorielle de reference, en variables CSS, avec un jeu de pas CHOISI pour le fond sombre (pas un eclaircissement automatique). Palette passee au validateur dans les deux modes, toutes paires : bande de clarte, plancher de chroma, separation daltonienne (pire paire ΔE 9,2 clair / 9,4 sombre) et contraste. En mode clair l'aqua tombe a 2,82:1 — la regle de relief s'applique, d'ou etiquettes visibles ET vue tableau, toutes deux presentes. La teinte de tendance (vert/rouge/gris) ne vit plus que dans la tuile de verdict, avec fleche et libelle : un etat ne se signale jamais par la couleur seule, et la meme couleur ne veut pas dire deux choses. LISIBILITE. Etiquetage selectif — l'extreme et le dernier mois, pas les 36 valeurs. Le texte ne porte jamais la couleur d'une serie (illisible en teinte claire) : l'identite vient du repere pose a cote. Filets de base pleins et discrets, jamais pointilles. Reticule au survol reliant les trois facettes au meme mois, et vue tableau depliable : aucune valeur n'est accessible au seul survol. En-tete refait en tuiles : verdict, qte/magasin du dernier mois, magasins vendeurs. Chiffres proportionnels sur les valeurs mises en avant, tabulaires seulement dans le tableau ou les colonnes s'alignent. Rendu verifie en image dans les deux themes avant livraison : la version precedente faisait passer l'etiquette du pic par-dessus le titre de la facette — elle bascule desormais sous le point quand elle toucherait le bandeau. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/globals.css | 17 + src/features/grid/components/heatmap-grid.tsx | 95 +++++- .../grid/components/network-charts.tsx | 311 +++++++++++------- 3 files changed, 286 insertions(+), 137 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index f9c6aed..c93cee5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -25,6 +25,16 @@ /* Slate 900 with opacity */ --border: #334155; /* Slate 700 */ + + /* ── Palette data-viz (3 séries) ── + Slots 1-3 de la palette catégorielle de référence, pas-de-teinte propre au + fond sombre. Validée sur ce fond : bande de clarté, plancher de chroma, + séparation daltonienne (pire paire ΔE 9,4) et contraste ≥ 3:1 — toutes + paires, pas seulement les adjacentes. Ne pas retoucher une valeur isolément : + c'est l'ENSEMBLE qui passe les contrôles. */ + --viz-1: #3987e5; + --viz-2: #d95926; + --viz-3: #199e70; --border-strong: #475569; /* Slate 600 */ @@ -70,6 +80,13 @@ --border: rgba(0, 0, 0, 0.07); --border-strong: rgba(0, 0, 0, 0.12); + /* Mêmes teintes, pas choisis pour le fond clair (et non un simple éclaircissement + des valeurs sombres). L'aqua passe sous 3:1 sur blanc : la « règle de relief » + s'applique — étiquettes directes visibles + vue tableau, toutes deux présentes. */ + --viz-1: #2a78d6; + --viz-2: #eb6834; + --viz-3: #1baf7a; + --text-primary: #1d1d1f; /* Apple dark */ --text-secondary: #515154; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index e5c9b40..19e955d 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -13,7 +13,7 @@ import { RowSelectionState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -38,6 +38,7 @@ import { computeStoresSeries, trendLabel, TREND_COLOR, + NB_MAGASINS_RESEAU, } from "@/features/grid/lib/network-trend"; import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts"; @@ -194,14 +195,60 @@ interface CellDetailData { receptions: number | null; } -/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */ +/** + * Tuile de statistique : intitulé en phrase, valeur en chiffres proportionnels. + * + * Pas de `tabular-nums` ici : à grande taille, des chiffres de largeur égale font + * paraître un nombre court anormalement lâche. Les chiffres tabulaires sont + * réservés aux colonnes qui s'alignent verticalement (tableau, axes). + */ +function TuileStat({ label, valeur, indice, couleur, icone: Icone }: { + label: string; + valeur: string; + indice?: string; + couleur?: string; + icone?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; +}) { + return ( +
+
+ {label} +
+
+ {Icone && } + + {valeur} + +
+ {indice &&
{indice}
} +
+ ); +} + +/** + * Carte « tendance réseau » : le verdict en tuiles, puis les trois séries + * mensuelles en petits multiples. + * + * La couleur de tendance (vert / rouge / gris) ne vit QUE dans la tuile de + * verdict, accompagnée d'une flèche et d'un libellé : un état ne se signale + * jamais par la couleur seule. Les courbes, elles, portent des teintes + * d'identité — sans quoi la même couleur voudrait dire deux choses. + */ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) { const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth); const { values, labels, direction, pct } = trend; const color = TREND_COLOR[direction]; - // Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une - // hausse due à l'élargissement de la diffusion d'une vraie accélération. const magasins = computeStoresSeries(row.nbMagReseauByMonth); + const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; + + const dernier = values.length - 1; + const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); + const verdict = trend.nouveau + ? "Nouveau" + : pct != null + ? `${pct >= 0 ? "+" : ""}${Math.round(pct * 100)} %` + : "—"; + return ( @@ -209,25 +256,41 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => {row.libelle1}

- Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"} - {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} - {(pct != null || trend.nouveau) && ( - <> · - {pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · } - {trendLabel(pct, trend.nouveau)} - {trend.surQteParMagasin && ( - (qté/magasin) - )} - - )} + Réseau · 12 mois glissants, mois en cours exclu

+ {!trend.hasData ? (
Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir.
) : ( - + <> +
+ + {trend.perStore && ( + t + v, 0) / trend.perStore.length, 1)} en moyenne`} + /> + )} + +
+ + )}
); diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 5529723..73ce6c7 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -7,6 +7,7 @@ * produit (`/produits`). Aucun changement de comportement. */ +import { useState } from "react"; import { TrendingUp, TrendingDown, Minus } from "lucide-react"; import { fmtMonthShort } from "@/features/grid/lib/months"; import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend"; @@ -50,66 +51,62 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { ); } -/** Courbe des quantités vendues (contexte) — bleu ciel. */ -export const QTY_COLOR = "#0ea5e9"; -/** Courbe des magasins vendeurs (contexte) — violet. */ -export const STORES_COLOR = "#8b5cf6"; +/** + * Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en + * variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et + * non un éclaircissement automatique (cf. `globals.css`). + */ +const SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"] as const; -/** Une bande du graphique : une série, son échelle, son style. */ -interface Bande { +/** Une facette du graphique : une série, son échelle, son titre. */ +interface Facette { titre: string; valeurs: number[]; couleur: string; hauteur: number; - /** Aire douce sous la courbe (réservée à la bande principale). */ + /** Lavis sous la courbe : réservé à la facette principale. */ aire?: boolean; - /** Trait pointillé + points évidés (séries de contexte). */ - pointille?: boolean; - /** Toutes les valeurs écrites, ou seulement la dernière. */ - etiquettes: "toutes" | "derniere"; - /** Décimales des étiquettes (la qté/magasin est souvent < 10). */ decimales?: number; - /** Suffixe de la dernière étiquette (« mag. »). */ - suffixe?: string; + unite?: string; } /** - * Ventes réseau mensuelles : jusqu'à trois bandes empilées sur le même axe. + * Ventes réseau mensuelles — petits multiples empilés sur un axe des mois commun. * - * ⚠️ LA BANDE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN, pas la quantité brute. - * La quantité brute confond la performance du produit et sa diffusion : elle - * monte dès qu'on référence le produit ailleurs, sans qu'il se vende mieux nulle - * part. C'est `qté / magasins vendeurs` qui dit si un produit marche ; les deux - * autres bandes sont là pour expliquer ses mouvements, pas pour les juger. + * ⚠️ PAS DE DOUBLE AXE. Superposer qté/magasin, volumes et magasins sur un même + * cadre demanderait deux ou trois échelles verticales : l'alignement entre elles + * serait arbitraire et inventerait des corrélations absentes des données. C'est + * l'erreur de graphique la plus commune. Trois mesures d'ordres de grandeur + * différents ⇒ trois facettes, une échelle chacune, le même axe horizontal. * - * ⚠️ DES BANDES, PAS DES COURBES SUPERPOSÉES. Deux échelles superposées - * plaçaient la courbe des magasins au-dessus de celle des quantités, ce qui se - * lit comme « il y a plus de magasins que de ventes » — un contresens. Chaque - * série a sa bande, alignée sur le même axe des mois : on compare des formes, - * jamais des hauteurs. + * ⚠️ LA FACETTE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN. La quantité brute confond + * la performance du produit et sa diffusion : elle monte dès qu'on référence le + * produit ailleurs, sans qu'il se vende mieux nulle part. Les deux autres + * facettes expliquent les mouvements de la première, elles ne les jugent pas. * - * ⚠️ LA DENSITÉ EST L'ENNEMI. Une version imprimait les 24 valeurs et titrait - * chaque bande dans le SVG : titres par-dessus les courbes, nombres des bords - * tronqués, étiquettes collées au trait. D'où les titres en HTML au-dessus du - * dessin, les étiquettes de bord ancrées vers l'intérieur, les bandes de - * contexte réduites à leur dernière valeur, et une infobulle par mois qui donne - * les chiffres exacts sans rien encombrer. + * ⚠️ ÉTIQUETAGE SÉLECTIF. Une valeur à côté de chaque point, c'est 36 nombres : + * illisible, et personne ne les lit. Seuls l'extrême et le dernier mois sont + * écrits ; le survol donne le mois exact et la vue tableau porte tout le reste, + * de sorte qu'aucune valeur n'est accessible uniquement au survol. + * + * Le texte ne porte jamais la couleur d'une série (une teinte claire est + * illisible en texte) : l'identité vient du repère coloré posé à côté. */ export function NetworkLineChart({ labels, values, - color, stores, perStore, }: { labels: string[]; values: number[]; - color: string; - /** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */ + /** Couleur héritée de la tendance : conservée pour l'API, plus utilisée ici — + * la teinte « tendance » reste au badge d'en-tête, les séries ont la leur. */ + color?: string; stores?: number[] | null; - /** Quantité par magasin vendeur — la vraie tendance, en bande principale. */ perStore?: number[] | null; }) { + const [survol, setSurvol] = useState(null); const n = values.length; const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0; const avecParMag = memeTaille(perStore); @@ -118,150 +115,222 @@ export function NetworkLineChart({ const fmt = (v: number, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); - // La bande principale porte la couleur de la tendance ; les bandes de - // contexte ont des couleurs fixes, pour que la teinte « tendance » ne - // désigne jamais qu'une seule chose dans le graphique. - const bandes: Bande[] = avecParMag - ? [ - { titre: "Qté / magasin", valeurs: perStore!, couleur: color, hauteur: 96, aire: true, etiquettes: "toutes", decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0 }, - { titre: "Quantités vendues", valeurs: values, couleur: QTY_COLOR, hauteur: 74, etiquettes: "toutes" }, - ] - : [ - { titre: "Quantités vendues", valeurs: values, couleur: color, hauteur: 116, aire: true, etiquettes: "toutes" }, - ]; + const facettes: Facette[] = []; + if (avecParMag) { + facettes.push({ + titre: "Qté / magasin", + valeurs: perStore!, + couleur: SERIE_COULEURS[0], + hauteur: 92, + aire: true, + decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0, + }); + facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[1], hauteur: 58 }); + } else { + facettes.push({ titre: "Quantités vendues", valeurs: values, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true }); + } if (avecMagasins) { - bandes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: STORES_COLOR, hauteur: 42, pointille: true, etiquettes: "derniere", suffixe: " mag." }); + facettes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: SERIE_COULEURS[2], hauteur: 46 }); } const W = 520; - const padL = 14, padR = 14, moisH = 18, hautPremiere = 16, ecart = 15; + const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12; const plotW = W - padL - padR; - const H = hautPremiere + bandes.reduce((t, b) => t + b.hauteur, 0) + ecart * (bandes.length - 1) + moisH; + const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH; const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); - /** Aux extrémités on ancre vers l'intérieur : sinon l'étiquette sort du cadre. */ + /** Aux extrémités, l'étiquette est ancrée vers l'intérieur : sinon elle sort du cadre. */ const ancrage = (i: number): "start" | "middle" | "end" => i === 0 ? "start" : i === n - 1 ? "end" : "middle"; - // Position verticale de chaque bande, empilée de haut en bas. - let curseur = hautPremiere; - const disposees = bandes.map((b) => { - const haut = curseur; - curseur += b.hauteur + ecart; - // Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un - // mois) : l'échelle part du minimum réel, sinon le point sort du cadre. - const min = Math.min(0, ...b.valeurs); - const max = Math.max(...b.valeurs, min + 1); + let curseur = 0; + const disposees = facettes.map((f) => { + const hautEntete = curseur; + const haut = curseur + enteteH; + curseur = haut + f.hauteur + ecart; + // Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un mois) : + // l'échelle part du minimum réel, sinon le point sort du cadre par le bas. + const min = Math.min(0, ...f.valeurs); + const max = Math.max(...f.valeurs, min + 1); const etendue = max - min || 1; - const y = (v: number) => haut + b.hauteur - ((v - min) / etendue) * b.hauteur; - return { ...b, haut, max, y }; + const y = (v: number) => haut + f.hauteur - ((v - min) / etendue) * f.hauteur; + const iMax = f.valeurs.indexOf(Math.max(...f.valeurs)); + return { ...f, hautEntete, haut, base: y(min), max, y, iMax }; }); + const basPlots = curseur - ecart; return (
- {/* Titres HORS du dessin : plus aucun recouvrement possible. */} -
- {disposees.map((b) => ( - - - {b.titre} - · max {fmt(b.max, b.decimales ?? 0)} - - ))} -
b.titre).join(", ")} + aria-label={"Ventes réseau mensuelles : " + disposees.map((f) => f.titre).join(", ")} + onMouseLeave={() => setSurvol(null)} > - {disposees.map((b) => { - const pts = b.valeurs.map((v, i) => `${x(i).toFixed(1)},${b.y(v).toFixed(1)}`).join(" "); - const base = b.y(Math.min(0, ...b.valeurs)); + {disposees.map((f) => { + const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" "); + // L'extrême et le dernier mois seulement : deux repères qui + // situent la courbe, sans noyer le dessin sous les nombres. + const aEtiqueter = [...new Set([f.iMax, n - 1])]; return ( - - - {b.aire && n > 1 && ( + + {/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */} + + + {f.titre} + + + max {fmt(f.max, f.decimales ?? 0)}{f.unite ?? ""} + + + {/* Ligne de base : filet plein, une nuance au-dessus du fond */} + + {f.aire && n > 1 && ( )} {n > 1 && ( - + )} - {b.valeurs.map((v, i) => ( + + {/* Points repères : extrême et dernier mois, cerclés de la couleur du fond */} + {aEtiqueter.map((i) => ( ))} - {b.valeurs.map((v, i) => { - if (b.etiquettes === "derniere" && i !== n - 1) return null; + {aEtiqueter.map((i) => { + // Un point proche du sommet verrait son étiquette empiéter sur le + // bandeau de la facette (constaté au rendu : « 58 » par-dessus + // « Qté / magasin »). Dans ce cas elle bascule SOUS le point. + const yPoint = f.y(f.valeurs[i]); + const dessus = yPoint - 8 >= f.haut + 8; return ( = 0 ? b.y(v) - 6 : b.y(v) + 12} + y={dessus ? yPoint - 8 : yPoint + 13} textAnchor={ancrage(i)} - fontSize={8.5} + fontSize={9} fontWeight={700} - fill={b.etiquettes === "derniere" ? b.couleur : "var(--text-primary)"} + fill="var(--text-primary)" > - {fmt(v, b.decimales ?? 0)}{i === n - 1 ? b.suffixe ?? "" : ""} + {fmt(f.valeurs[i], f.decimales ?? 0)} ); })} + + {/* Point mis en avant au survol */} + {survol != null && ( + + )} ); })} - {/* Axe des mois, commun à toutes les bandes */} + {/* Réticule : un seul trait vertical relie les trois facettes au même mois */} + {survol != null && ( + + )} + + {/* Axe des mois, commun aux facettes */} {labels.map((lab, i) => ( - + {fmtMonthShort(lab)} ))} - {/* Zones de survol : valeurs exactes du mois, sans encombrer le dessin. */} + {/* Zones de survol : larges cibles, une par mois */} {labels.map((lab, i) => ( - - {`${fmtMonthShort(lab)} · ` + - disposees.map((b) => `${b.titre} ${fmt(b.valeurs[i], b.decimales ?? 0)}`).join(" · ")} - - + onMouseEnter={() => setSurvol(i)} + /> ))} + + {/* Lecture du mois survolé, en encre neutre : la couleur reste aux repères */} +
+ {survol != null ? ( + <> + {fmtMonthShort(labels[survol])} + {disposees.map((f) => ( + + + {f.titre} + + {fmt(f.valeurs[survol], f.decimales ?? 0)} + + + ))} + + ) : ( + Survolez un mois pour le détail. + )} +
+ + {/* Vue tableau : l'équivalent accessible, et le recours quand une teinte + passe sous le seuil de contraste. Aucune valeur n'est réservée au survol. */} +
+ + Voir les valeurs mois par mois + +
+ + + + + {disposees.map((f) => ( + + ))} + + + + {labels.map((lab, i) => ( + + + {disposees.map((f) => ( + + ))} + + ))} + +
Mois{f.titre}
{fmtMonthShort(lab)} + {fmt(f.valeurs[i], f.decimales ?? 0)} +
+
+
); }