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}
+
+ >
)}
);
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. */}
-
+
+ {/* 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
+
+