"use client";
/**
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
*
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche
* 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";
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return
-
;
const { values, direction, pct, nouveau } = trend;
const color = TREND_COLOR[direction];
const W = 46, H = 18, n = values.length;
const max = Math.max(...values, 1), min = Math.min(...values, 0);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
const y = H - 1 - ((v - min) / range) * (H - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
return (
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% : 4 derniers mois vs 4 premiers — cliquer pour le détail`
: nouveau
? "Nouveau : aucune vente sur les 4 premiers mois de la fenêtre — cliquer pour le détail"
: "Tendance réseau — cliquer pour le détail"}>
);
}
/**
* 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 facette du graphique : une série, son échelle, son titre. */
interface Facette {
titre: string;
valeurs: number[];
couleur: string;
hauteur: number;
/** Lavis sous la courbe : réservé à la facette principale. */
aire?: boolean;
decimales?: number;
unite?: string;
}
/**
* Ventes réseau mensuelles — petits multiples empilés sur un axe des mois commun.
*
* ⚠️ 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.
*
* ⚠️ 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.
*
* ⚠️ É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,
stores,
perStore,
}: {
labels: string[];
values: number[];
/** 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;
perStore?: number[] | null;
}) {
const [survol, setSurvol] = useState(null);
const [vue, setVue] = useState<"graphique" | "tableau">("graphique");
const n = values.length;
const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0;
const avecParMag = memeTaille(perStore);
const avecMagasins = memeTaille(stores);
const fmt = (v: number, d = 0) =>
v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
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) {
facettes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: SERIE_COULEURS[2], hauteur: 46 });
}
const W = 520;
const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12;
const plotW = W - padL - padR;
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, 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";
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 + 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 (
{/* Bascule : la même surface sert au dessin ou aux chiffres. */}
{(["graphique", "tableau"] as const).map((v) => (
))}
{vue === "graphique" ? (
) : (
/* Vue tableau : même panneau, MÊME HAUTEUR.
Un dépliant aurait fait grandir la carte à l'ouverture — exactement ce
qu'on venait de corriger sur le survol. Le tableau occupe donc la place
du graphique et défile à l'intérieur : la carte garde sa taille, et
aucune valeur n'est réservée au survol. */
Mois
{disposees.map((f) => (
{f.titre}
))}
{labels.map((lab, i) => (
{fmtMonthShort(lab)}
{disposees.map((f) => (
{fmt(f.valeurs[i], f.decimales ?? 0)}
))}
))}
)}
);
}
/**
* Deux courbes superposées sur le même axe (comparatif local vs réseau).
* Les valeurs sont normalisées ensemble pour rester comparables visuellement.
*/
export function DualLineChart({
labels,
series,
}: {
labels: string[];
series: Array<{ name: string; values: number[]; color: string }>;
}) {
const W = 620, H = 220;
const padL = 10, padR = 10, padT = 26, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = labels.length;
const maxV = Math.max(1, ...series.flatMap((s) => s.values));
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
return (