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)} +
+
+
); }