Carte tendance : refonte du graphique selon les regles data-viz

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-08-01 09:58:31 +00:00
1 parent eaac945345
commit 5ad222431d
3 files changed
+286 -137

No files matched your search

+17
View File
@@ -25,6 +25,16 @@
/* Slate 900 with opacity */ /* Slate 900 with opacity */
--border: #334155; --border: #334155;
/* Slate 700 */ /* 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; --border-strong: #475569;
/* Slate 600 */ /* Slate 600 */
@@ -70,6 +80,13 @@
--border: rgba(0, 0, 0, 0.07); --border: rgba(0, 0, 0, 0.07);
--border-strong: rgba(0, 0, 0, 0.12); --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; --text-primary: #1d1d1f;
/* Apple dark */ /* Apple dark */
--text-secondary: #515154; --text-secondary: #515154;
+79 -16
View File
@@ -13,7 +13,7 @@ import {
RowSelectionState, RowSelectionState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual"; 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
@@ -38,6 +38,7 @@ import {
computeStoresSeries, computeStoresSeries,
trendLabel, trendLabel,
TREND_COLOR, TREND_COLOR,
NB_MAGASINS_RESEAU,
} from "@/features/grid/lib/network-trend"; } from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts"; import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
@@ -194,14 +195,60 @@ interface CellDetailData {
receptions: number | null; 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 (
<div className="rounded-xl px-3 py-2.5 flex-1 min-w-[140px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{Icone && <Icone className="w-4 h-4 shrink-0" style={{ color: couleur ?? "var(--text-primary)" }} />}
<span className="text-[20px] font-bold leading-tight" style={{ color: couleur ?? "var(--text-primary)" }}>
{valeur}
</span>
</div>
{indice && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{indice}</div>}
</div>
);
}
/**
* 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 }) { function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth); const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
const { values, labels, direction, pct } = trend; const { values, labels, direction, pct } = trend;
const color = TREND_COLOR[direction]; 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 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 ( return (
<DialogContent className="max-w-xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}> <DialogContent className="max-w-xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
<DialogHeader> <DialogHeader>
@@ -209,25 +256,41 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
{row.libelle1} {row.libelle1}
</DialogTitle> </DialogTitle>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}> <p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"} Réseau · 12 mois glissants, mois en cours exclu
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
{(pct != null || trend.nouveau) && (
<> · <span className="font-bold" style={{ color }}>
{pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · </>}
{trendLabel(pct, trend.nouveau)}
{trend.surQteParMagasin && (
<span className="font-normal" style={{ color: "var(--text-muted)" }}> (qté/magasin)</span>
)}
</span></>
)}
</p> </p>
</DialogHeader> </DialogHeader>
{!trend.hasData ? ( {!trend.hasData ? (
<div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}> <div className="mt-3 rounded-xl p-4 text-center text-[13px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-muted)" }}>
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir. Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
</div> </div>
) : ( ) : (
<NetworkLineChart labels={labels} values={values} color={color} stores={magasins?.values} perStore={trend.perStore} /> <>
<div className="flex flex-wrap gap-2 mt-1">
<TuileStat
label="Tendance"
valeur={verdict}
indice={trend.surQteParMagasin
? `${trendLabel(pct, trend.nouveau)} · sur la qté/magasin`
: `${trendLabel(pct, trend.nouveau)} · sur les volumes (magasins inconnus)`}
couleur={color}
icone={Fleche}
/>
{trend.perStore && (
<TuileStat
label="Qté / magasin"
valeur={fmt(trend.perStore[dernier], trend.perStore[dernier] < 10 ? 1 : 0)}
indice={`dernier mois · ${fmt(trend.perStore.reduce((t, v) => t + v, 0) / trend.perStore.length, 1)} en moyenne`}
/>
)}
<TuileStat
label="Magasins vendeurs"
valeur={magasins ? fmt(magasins.values[dernier]) : fmt(row.nbMagasinsReseau ?? 0)}
indice={`sur ${NB_MAGASINS_RESEAU} du réseau`}
/>
</div>
<NetworkLineChart labels={labels} values={values} stores={magasins?.values} perStore={trend.perStore} />
</>
)} )}
</DialogContent> </DialogContent>
); );
+190 -121
View File
@@ -7,6 +7,7 @@
* produit (`/produits`). Aucun changement de comportement. * produit (`/produits`). Aucun changement de comportement.
*/ */
import { useState } from "react";
import { TrendingUp, TrendingDown, Minus } from "lucide-react"; import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { fmtMonthShort } from "@/features/grid/lib/months"; import { fmtMonthShort } from "@/features/grid/lib/months";
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend"; 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"; * Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en
/** Courbe des magasins vendeurs (contexte) — violet. */ * variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et
export const STORES_COLOR = "#8b5cf6"; * 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. */ /** Une facette du graphique : une série, son échelle, son titre. */
interface Bande { interface Facette {
titre: string; titre: string;
valeurs: number[]; valeurs: number[];
couleur: string; couleur: string;
hauteur: number; hauteur: number;
/** Aire douce sous la courbe (réservée à la bande principale). */ /** Lavis sous la courbe : réservé à la facette principale. */
aire?: boolean; 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; decimales?: number;
/** Suffixe de la dernière étiquette (« mag. »). */ unite?: string;
suffixe?: 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. * ⚠️ PAS DE DOUBLE AXE. Superposer qté/magasin, volumes et magasins sur un même
* La quantité brute confond la performance du produit et sa diffusion : elle * cadre demanderait deux ou trois échelles verticales : l'alignement entre elles
* monte dès qu'on référence le produit ailleurs, sans qu'il se vende mieux nulle * serait arbitraire et inventerait des corrélations absentes des données. C'est
* part. C'est `qté / magasins vendeurs` qui dit si un produit marche ; les deux * l'erreur de graphique la plus commune. Trois mesures d'ordres de grandeur
* autres bandes sont là pour expliquer ses mouvements, pas pour les juger. * différents ⇒ trois facettes, une échelle chacune, le même axe horizontal.
* *
* ⚠️ DES BANDES, PAS DES COURBES SUPERPOSÉES. Deux échelles superposées * ⚠️ LA FACETTE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN. La quantité brute confond
* plaçaient la courbe des magasins au-dessus de celle des quantités, ce qui se * la performance du produit et sa diffusion : elle monte dès qu'on référence le
* lit comme « il y a plus de magasins que de ventes » — un contresens. Chaque * produit ailleurs, sans qu'il se vende mieux nulle part. Les deux autres
* série a sa bande, alignée sur le même axe des mois : on compare des formes, * facettes expliquent les mouvements de la première, elles ne les jugent pas.
* jamais des hauteurs.
* *
* ⚠️ LA DENSITÉ EST L'ENNEMI. Une version imprimait les 24 valeurs et titrait * ⚠️ ÉTIQUETAGE SÉLECTIF. Une valeur à côté de chaque point, c'est 36 nombres :
* chaque bande dans le SVG : titres par-dessus les courbes, nombres des bords * illisible, et personne ne les lit. Seuls l'extrême et le dernier mois sont
* tronqués, étiquettes collées au trait. D'où les titres en HTML au-dessus du * écrits ; le survol donne le mois exact et la vue tableau porte tout le reste,
* dessin, les étiquettes de bord ancrées vers l'intérieur, les bandes de * de sorte qu'aucune valeur n'est accessible uniquement au survol.
* contexte réduites à leur dernière valeur, et une infobulle par mois qui donne *
* les chiffres exacts sans rien encombrer. * 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({ export function NetworkLineChart({
labels, labels,
values, values,
color,
stores, stores,
perStore, perStore,
}: { }: {
labels: string[]; labels: string[];
values: number[]; values: number[];
color: string; /** Couleur héritée de la tendance : conservée pour l'API, plus utilisée ici —
/** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */ * la teinte « tendance » reste au badge d'en-tête, les séries ont la leur. */
color?: string;
stores?: number[] | null; stores?: number[] | null;
/** Quantité par magasin vendeur — la vraie tendance, en bande principale. */
perStore?: number[] | null; perStore?: number[] | null;
}) { }) {
const [survol, setSurvol] = useState<number | null>(null);
const n = values.length; const n = values.length;
const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0; const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0;
const avecParMag = memeTaille(perStore); const avecParMag = memeTaille(perStore);
@@ -118,150 +115,222 @@ export function NetworkLineChart({
const fmt = (v: number, d = 0) => const fmt = (v: number, d = 0) =>
v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d }); v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
// La bande principale porte la couleur de la tendance ; les bandes de const facettes: Facette[] = [];
// contexte ont des couleurs fixes, pour que la teinte « tendance » ne if (avecParMag) {
// désigne jamais qu'une seule chose dans le graphique. facettes.push({
const bandes: Bande[] = avecParMag titre: "Qté / magasin",
? [ valeurs: perStore!,
{ titre: "Qté / magasin", valeurs: perStore!, couleur: color, hauteur: 96, aire: true, etiquettes: "toutes", decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0 }, couleur: SERIE_COULEURS[0],
{ titre: "Quantités vendues", valeurs: values, couleur: QTY_COLOR, hauteur: 74, etiquettes: "toutes" }, hauteur: 92,
] aire: true,
: [ decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0,
{ titre: "Quantités vendues", valeurs: values, couleur: color, hauteur: 116, aire: true, etiquettes: "toutes" }, });
]; 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) { 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 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 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); 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" => const ancrage = (i: number): "start" | "middle" | "end" =>
i === 0 ? "start" : i === n - 1 ? "end" : "middle"; i === 0 ? "start" : i === n - 1 ? "end" : "middle";
// Position verticale de chaque bande, empilée de haut en bas. let curseur = 0;
let curseur = hautPremiere; const disposees = facettes.map((f) => {
const disposees = bandes.map((b) => { const hautEntete = curseur;
const haut = curseur; const haut = curseur + enteteH;
curseur += b.hauteur + ecart; curseur = haut + f.hauteur + ecart;
// Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un // Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un mois) :
// mois) : l'échelle part du minimum réel, sinon le point sort du cadre. // l'échelle part du minimum réel, sinon le point sort du cadre par le bas.
const min = Math.min(0, ...b.valeurs); const min = Math.min(0, ...f.valeurs);
const max = Math.max(...b.valeurs, min + 1); const max = Math.max(...f.valeurs, min + 1);
const etendue = max - min || 1; const etendue = max - min || 1;
const y = (v: number) => haut + b.hauteur - ((v - min) / etendue) * b.hauteur; const y = (v: number) => haut + f.hauteur - ((v - min) / etendue) * f.hauteur;
return { ...b, haut, max, y }; const iMax = f.valeurs.indexOf(Math.max(...f.valeurs));
return { ...f, hautEntete, haut, base: y(min), max, y, iMax };
}); });
const basPlots = curseur - ecart;
return ( return (
<div className="mt-3 w-full overflow-x-auto"> <div className="mt-3 w-full overflow-x-auto">
{/* Titres HORS du dessin : plus aucun recouvrement possible. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mb-1.5">
{disposees.map((b) => (
<span key={b.titre} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span
className="inline-block w-3"
style={b.pointille
? { borderTop: `3px dashed ${b.couleur}` }
: { height: 3, borderRadius: 9999, background: b.couleur }}
/>
{b.titre}
<span className="tabular-nums" style={{ color: "var(--text-muted)" }}>· max {fmt(b.max, b.decimales ?? 0)}</span>
</span>
))}
</div>
<svg <svg
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
className="w-full" className="w-full"
style={{ minWidth: 420 }} style={{ minWidth: 420 }}
role="img" role="img"
aria-label={"Ventes réseau mensuelles : " + disposees.map((b) => b.titre).join(", ")} aria-label={"Ventes réseau mensuelles : " + disposees.map((f) => f.titre).join(", ")}
onMouseLeave={() => setSurvol(null)}
> >
{disposees.map((b) => { {disposees.map((f) => {
const pts = b.valeurs.map((v, i) => `${x(i).toFixed(1)},${b.y(v).toFixed(1)}`).join(" "); const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" ");
const base = b.y(Math.min(0, ...b.valeurs)); // 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 ( return (
<g key={b.titre}> <g key={f.titre}>
<line x1={padL} y1={base} x2={padL + plotW} y2={base} stroke="var(--border)" strokeWidth={1} /> {/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */}
{b.aire && n > 1 && ( <rect x={padL} y={f.hautEntete + 3} width={12} height={3} rx={1.5} fill={f.couleur} />
<text x={padL + 18} y={f.hautEntete + 7} fontSize={9.5} fontWeight={600} fill="var(--text-secondary)" dominantBaseline="middle">
{f.titre}
</text>
<text x={padL + plotW} y={f.hautEntete + 7} textAnchor="end" fontSize={9} fill="var(--text-muted)" dominantBaseline="middle">
max {fmt(f.max, f.decimales ?? 0)}{f.unite ?? ""}
</text>
{/* Ligne de base : filet plein, une nuance au-dessus du fond */}
<line x1={padL} y1={f.base} x2={padL + plotW} y2={f.base} stroke="var(--border)" strokeWidth={1} />
{f.aire && n > 1 && (
<polygon <polygon
points={`${padL},${base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${base.toFixed(1)}`} points={`${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`}
fill={b.couleur} fill={f.couleur}
opacity={0.08} opacity={0.1}
/> />
)} )}
{n > 1 && ( {n > 1 && (
<polyline <polyline points={pts} fill="none" stroke={f.couleur} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
points={pts}
fill="none"
stroke={b.couleur}
strokeWidth={b.pointille ? 1.75 : 2}
strokeDasharray={b.pointille ? "4 3" : undefined}
strokeLinejoin="round"
strokeLinecap="round"
/>
)} )}
{b.valeurs.map((v, i) => (
{/* Points repères : extrême et dernier mois, cerclés de la couleur du fond */}
{aEtiqueter.map((i) => (
<circle <circle
key={`${b.titre}-${labels[i]}`} key={`pt-${f.titre}-${i}`}
cx={x(i)} cx={x(i)}
cy={b.y(v)} cy={f.y(f.valeurs[i])}
r={b.pointille ? 2.2 : 2.6} r={4}
// Points évidés sur les séries de contexte : elles restent fill={f.couleur}
// distinctes même en noir et blanc. stroke="var(--bg-surface)"
fill={b.pointille ? "var(--bg-surface)" : b.couleur} strokeWidth={2}
stroke={b.pointille ? b.couleur : undefined}
strokeWidth={b.pointille ? 1.3 : undefined}
/> />
))} ))}
{b.valeurs.map((v, i) => { {aEtiqueter.map((i) => {
if (b.etiquettes === "derniere" && i !== n - 1) return null; // 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 ( return (
<text <text
key={`lab-${b.titre}-${labels[i]}`} key={`lab-${f.titre}-${i}`}
x={x(i)} x={x(i)}
y={v >= 0 ? b.y(v) - 6 : b.y(v) + 12} y={dessus ? yPoint - 8 : yPoint + 13}
textAnchor={ancrage(i)} textAnchor={ancrage(i)}
fontSize={8.5} fontSize={9}
fontWeight={700} 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)}
</text> </text>
); );
})} })}
{/* Point mis en avant au survol */}
{survol != null && (
<circle
cx={x(survol)}
cy={f.y(f.valeurs[survol])}
r={3.5}
fill={f.couleur}
stroke="var(--bg-surface)"
strokeWidth={2}
/>
)}
</g> </g>
); );
})} })}
{/* Axe des mois, commun à toutes les bandes */} {/* Réticule : un seul trait vertical relie les trois facettes au même mois */}
{survol != null && (
<line x1={x(survol)} y1={0} x2={x(survol)} y2={basPlots} stroke="var(--border-strong)" strokeWidth={1} />
)}
{/* Axe des mois, commun aux facettes */}
{labels.map((lab, i) => ( {labels.map((lab, i) => (
<text key={`mois-${lab}`} x={x(i)} y={H - 5} textAnchor={ancrage(i)} fontSize={8} fill="var(--text-muted)"> <text
key={`mois-${lab}`}
x={x(i)}
y={H - 5}
textAnchor={ancrage(i)}
fontSize={8.5}
fill={survol === i ? "var(--text-primary)" : "var(--text-muted)"}
fontWeight={survol === i ? 700 : 400}
>
{fmtMonthShort(lab)} {fmtMonthShort(lab)}
</text> </text>
))} ))}
{/* Zones de survol : valeurs exactes du mois, sans encombrer le dessin. */} {/* Zones de survol : larges cibles, une par mois */}
{labels.map((lab, i) => ( {labels.map((lab, i) => (
<rect <rect
key={`survol-${lab}`} key={`survol-${lab}`}
x={x(i) - plotW / (2 * Math.max(1, n - 1))} x={x(i) - plotW / (2 * Math.max(1, n - 1))}
y={0} y={0}
width={plotW / Math.max(1, n - 1)} width={plotW / Math.max(1, n - 1)}
height={H - moisH} height={H}
fill="transparent" fill="transparent"
> onMouseEnter={() => setSurvol(i)}
<title> />
{`${fmtMonthShort(lab)} · ` +
disposees.map((b) => `${b.titre} ${fmt(b.valeurs[i], b.decimales ?? 0)}`).join(" · ")}
</title>
</rect>
))} ))}
</svg> </svg>
{/* Lecture du mois survolé, en encre neutre : la couleur reste aux repères */}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] min-h-[16px]" style={{ color: "var(--text-secondary)" }}>
{survol != null ? (
<>
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{fmtMonthShort(labels[survol])}</span>
{disposees.map((f) => (
<span key={`tt-${f.titre}`} className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-[3px] rounded-full" style={{ background: f.couleur }} />
{f.titre}
<span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>
{fmt(f.valeurs[survol], f.decimales ?? 0)}
</span>
</span>
))}
</>
) : (
<span style={{ color: "var(--text-muted)" }}>Survolez un mois pour le détail.</span>
)}
</div>
{/* 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. */}
<details className="mt-2">
<summary className="text-[11px] cursor-pointer select-none" style={{ color: "var(--text-muted)" }}>
Voir les valeurs mois par mois
</summary>
<div className="mt-1.5 overflow-x-auto">
<table className="w-full text-[11px] tabular-nums" style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ color: "var(--text-muted)" }}>
<th className="text-left font-medium py-1 pr-2">Mois</th>
{disposees.map((f) => (
<th key={`th-${f.titre}`} className="text-right font-medium py-1 pl-2">{f.titre}</th>
))}
</tr>
</thead>
<tbody>
{labels.map((lab, i) => (
<tr key={`tr-${lab}`} style={{ borderTop: "1px solid var(--border)" }}>
<td className="py-1 pr-2" style={{ color: "var(--text-secondary)" }}>{fmtMonthShort(lab)}</td>
{disposees.map((f) => (
<td key={`td-${f.titre}-${lab}`} className="text-right py-1 pl-2" style={{ color: "var(--text-primary)" }}>
{fmt(f.valeurs[i], f.decimales ?? 0)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</details>
</div> </div>
); );
} }