mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 05:28:11 +02:00
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:
3 files changed
+286
-137
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user