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 */
--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;
+79 -16
View File
@@ -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 (
<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 }) {
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 (
<DialogContent className="max-w-xl" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
<DialogHeader>
@@ -209,25 +256,41 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
{row.libelle1}
</DialogTitle>
<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"}
{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></>
)}
Réseau · 12 mois glissants, mois en cours exclu
</p>
</DialogHeader>
{!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)" }}>
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
</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>
);
+190 -121
View File
@@ -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<number | null>(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 (
<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
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ minWidth: 420 }}
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) => {
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 (
<g key={b.titre}>
<line x1={padL} y1={base} x2={padL + plotW} y2={base} stroke="var(--border)" strokeWidth={1} />
{b.aire && n > 1 && (
<g key={f.titre}>
{/* Bandeau de facette : repère coloré + titre + maximum, en encre neutre */}
<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
points={`${padL},${base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${base.toFixed(1)}`}
fill={b.couleur}
opacity={0.08}
points={`${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`}
fill={f.couleur}
opacity={0.1}
/>
)}
{n > 1 && (
<polyline
points={pts}
fill="none"
stroke={b.couleur}
strokeWidth={b.pointille ? 1.75 : 2}
strokeDasharray={b.pointille ? "4 3" : undefined}
strokeLinejoin="round"
strokeLinecap="round"
/>
<polyline points={pts} fill="none" stroke={f.couleur} strokeWidth={2} 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
key={`${b.titre}-${labels[i]}`}
key={`pt-${f.titre}-${i}`}
cx={x(i)}
cy={b.y(v)}
r={b.pointille ? 2.2 : 2.6}
// Points évidés sur les séries de contexte : elles restent
// distinctes même en noir et blanc.
fill={b.pointille ? "var(--bg-surface)" : b.couleur}
stroke={b.pointille ? b.couleur : undefined}
strokeWidth={b.pointille ? 1.3 : undefined}
cy={f.y(f.valeurs[i])}
r={4}
fill={f.couleur}
stroke="var(--bg-surface)"
strokeWidth={2}
/>
))}
{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 (
<text
key={`lab-${b.titre}-${labels[i]}`}
key={`lab-${f.titre}-${i}`}
x={x(i)}
y={v >= 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)}
</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>
);
})}
{/* 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) => (
<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)}
</text>
))}
{/* Zones de survol : valeurs exactes du mois, sans encombrer le dessin. */}
{/* Zones de survol : larges cibles, une par mois */}
{labels.map((lab, i) => (
<rect
key={`survol-${lab}`}
x={x(i) - plotW / (2 * Math.max(1, n - 1))}
y={0}
width={plotW / Math.max(1, n - 1)}
height={H - moisH}
height={H}
fill="transparent"
>
<title>
{`${fmtMonthShort(lab)} · ` +
disposees.map((b) => `${b.titre} ${fmt(b.valeurs[i], b.decimales ?? 0)}`).join(" · ")}
</title>
</rect>
onMouseEnter={() => setSurvol(i)}
/>
))}
</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>
);
}