mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Carte tendance : hauteur figee, bascule Graphique/Tableau, cellules copiables
1. LA CARTE CHANGEAIT DE TAILLE PENDANT LE SURVOL.
La lecture du mois vivait sous le graphique, en HTML : selon la longueur
des valeurs elle passait sur deux lignes et la carte sautait. Elle est
desormais dessinee DANS le SVG, dont la geometrie est figee par le
viewBox — infobulle ancree au reticule, qui bascule a gauche quand elle
deborderait a droite.
Deux causes residuelles trouvees a la mesure, pas a la lecture :
- le SVG etait dimensionne en pourcentage, donc sa hauteur dependait de
la largeur : il porte maintenant une hauteur en pixels ;
- un SVG en ligne traine l'espace sous la ligne de base (6 px mesures),
ce qui decalait la vue tableau : `display:block`.
Verifie en pilotant le composant reellement monte : 382,5 px au repos,
sur quatre positions de survol et dans les deux vues, en clair comme en
sombre.
2. LES VALEURS MOIS PAR MOIS SANS AGRANDIR LA CARTE.
Le deplaint aurait fait grandir la carte a l'ouverture — exactement ce
qu'on venait de corriger. Le tableau occupe donc la place du graphique
dans un panneau de MEME HAUTEUR et defile a l'interieur.
3. REFERENCE ET EAN COPIABLES, comme le code interne.
La copie etait ecrite en ligne dans la colonne « Code interne », avec un
`eslint-disable react-hooks/rules-of-hooks` : un useState dans le corps
d'un cell() de TanStack n'est pas rendu au meme endroit d'un rendu a
l'autre. Extraite en composant, la derogation disparait et les trois
colonnes partagent le meme comportement.
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
+404
-98
No files matched your search
+229
@@ -0,0 +1,229 @@
|
|||||||
|
// <stdin>
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
// src/features/grid/components/network-charts.tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
// src/features/grid/lib/months.ts
|
||||||
|
function fmtMonthShort(lab) {
|
||||||
|
const [y, m] = lab.split("-");
|
||||||
|
return `${m ?? lab}/${(y ?? "").slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/features/grid/components/network-charts.tsx
|
||||||
|
import { jsx, jsxs } from "react/jsx-runtime";
|
||||||
|
var SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"];
|
||||||
|
function NetworkLineChart({
|
||||||
|
labels: labels2,
|
||||||
|
values: values2,
|
||||||
|
stores: stores2,
|
||||||
|
perStore: perStore2
|
||||||
|
}) {
|
||||||
|
const [survol, setSurvol] = useState(null);
|
||||||
|
const n = values2.length;
|
||||||
|
const memeTaille = (s) => Array.isArray(s) && s.length === n && n > 0;
|
||||||
|
const avecParMag = memeTaille(perStore2);
|
||||||
|
const avecMagasins = memeTaille(stores2);
|
||||||
|
const fmt = (v, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
const facettes = [];
|
||||||
|
if (avecParMag) {
|
||||||
|
facettes.push({
|
||||||
|
titre: "Qt\xE9 / magasin",
|
||||||
|
valeurs: perStore2,
|
||||||
|
couleur: SERIE_COULEURS[0],
|
||||||
|
hauteur: 92,
|
||||||
|
aire: true,
|
||||||
|
decimales: perStore2.some((v) => v > 0 && v < 10) ? 1 : 0
|
||||||
|
});
|
||||||
|
facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[1], hauteur: 58 });
|
||||||
|
} else {
|
||||||
|
facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true });
|
||||||
|
}
|
||||||
|
if (avecMagasins) {
|
||||||
|
facettes.push({ titre: "Magasins vendeurs", valeurs: stores2, 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) => n > 1 ? padL + i / (n - 1) * plotW : padL + plotW / 2;
|
||||||
|
const ancrage = (i) => 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;
|
||||||
|
const min = Math.min(0, ...f.valeurs);
|
||||||
|
const max = Math.max(...f.valeurs, min + 1);
|
||||||
|
const etendue = max - min || 1;
|
||||||
|
const y = (v) => 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 /* @__PURE__ */ jsxs("div", { className: "mt-3 w-full overflow-x-auto", children: [
|
||||||
|
/* @__PURE__ */ jsxs(
|
||||||
|
"svg",
|
||||||
|
{
|
||||||
|
viewBox: `0 0 ${W} ${H}`,
|
||||||
|
className: "w-full",
|
||||||
|
style: { minWidth: 420 },
|
||||||
|
role: "img",
|
||||||
|
"aria-label": "Ventes r\xE9seau mensuelles : " + disposees.map((f) => f.titre).join(", "),
|
||||||
|
onMouseLeave: () => setSurvol(null),
|
||||||
|
children: [
|
||||||
|
disposees.map((f) => {
|
||||||
|
const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" ");
|
||||||
|
const aEtiqueter = [.../* @__PURE__ */ new Set([f.iMax, n - 1])];
|
||||||
|
return /* @__PURE__ */ jsxs("g", { children: [
|
||||||
|
/* @__PURE__ */ jsx("rect", { x: padL, y: f.hautEntete + 3, width: 12, height: 3, rx: 1.5, fill: f.couleur }),
|
||||||
|
/* @__PURE__ */ jsx("text", { x: padL + 18, y: f.hautEntete + 7, fontSize: 9.5, fontWeight: 600, fill: "var(--text-secondary)", dominantBaseline: "middle", children: f.titre }),
|
||||||
|
/* @__PURE__ */ jsxs("text", { x: padL + plotW, y: f.hautEntete + 7, textAnchor: "end", fontSize: 9, fill: "var(--text-muted)", dominantBaseline: "middle", children: [
|
||||||
|
"max ",
|
||||||
|
fmt(f.max, f.decimales ?? 0),
|
||||||
|
f.unite ?? ""
|
||||||
|
] }),
|
||||||
|
/* @__PURE__ */ jsx("line", { x1: padL, y1: f.base, x2: padL + plotW, y2: f.base, stroke: "var(--border)", strokeWidth: 1 }),
|
||||||
|
f.aire && n > 1 && /* @__PURE__ */ jsx(
|
||||||
|
"polygon",
|
||||||
|
{
|
||||||
|
points: `${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`,
|
||||||
|
fill: f.couleur,
|
||||||
|
opacity: 0.1
|
||||||
|
}
|
||||||
|
),
|
||||||
|
n > 1 && /* @__PURE__ */ jsx("polyline", { points: pts, fill: "none", stroke: f.couleur, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }),
|
||||||
|
aEtiqueter.map((i) => /* @__PURE__ */ jsx(
|
||||||
|
"circle",
|
||||||
|
{
|
||||||
|
cx: x(i),
|
||||||
|
cy: f.y(f.valeurs[i]),
|
||||||
|
r: 4,
|
||||||
|
fill: f.couleur,
|
||||||
|
stroke: "var(--bg-surface)",
|
||||||
|
strokeWidth: 2
|
||||||
|
},
|
||||||
|
`pt-${f.titre}-${i}`
|
||||||
|
)),
|
||||||
|
aEtiqueter.map((i) => {
|
||||||
|
const yPoint = f.y(f.valeurs[i]);
|
||||||
|
const dessus = yPoint - 8 >= f.haut + 8;
|
||||||
|
return /* @__PURE__ */ jsx(
|
||||||
|
"text",
|
||||||
|
{
|
||||||
|
x: x(i),
|
||||||
|
y: dessus ? yPoint - 8 : yPoint + 13,
|
||||||
|
textAnchor: ancrage(i),
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
fill: "var(--text-primary)",
|
||||||
|
children: fmt(f.valeurs[i], f.decimales ?? 0)
|
||||||
|
},
|
||||||
|
`lab-${f.titre}-${i}`
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
survol != null && /* @__PURE__ */ jsx(
|
||||||
|
"circle",
|
||||||
|
{
|
||||||
|
cx: x(survol),
|
||||||
|
cy: f.y(f.valeurs[survol]),
|
||||||
|
r: 3.5,
|
||||||
|
fill: f.couleur,
|
||||||
|
stroke: "var(--bg-surface)",
|
||||||
|
strokeWidth: 2
|
||||||
|
}
|
||||||
|
)
|
||||||
|
] }, f.titre);
|
||||||
|
}),
|
||||||
|
survol != null && /* @__PURE__ */ jsx("line", { x1: x(survol), y1: 0, x2: x(survol), y2: basPlots, stroke: "var(--border-strong)", strokeWidth: 1 }),
|
||||||
|
labels2.map((lab, i) => /* @__PURE__ */ jsx(
|
||||||
|
"text",
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
children: fmtMonthShort(lab)
|
||||||
|
},
|
||||||
|
`mois-${lab}`
|
||||||
|
)),
|
||||||
|
survol != null && (() => {
|
||||||
|
const largeur = 158;
|
||||||
|
const hauteur = 16 + disposees.length * 13 + 8;
|
||||||
|
const gauche = x(survol) + 10 + largeur > W - 4;
|
||||||
|
const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10;
|
||||||
|
return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [
|
||||||
|
/* @__PURE__ */ jsx(
|
||||||
|
"rect",
|
||||||
|
{
|
||||||
|
x: bx,
|
||||||
|
y: 4,
|
||||||
|
width: largeur,
|
||||||
|
height: hauteur,
|
||||||
|
rx: 8,
|
||||||
|
fill: "var(--bg-elevated)",
|
||||||
|
stroke: "var(--border)",
|
||||||
|
strokeWidth: 1
|
||||||
|
}
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ jsx("text", { x: bx + 9, y: 17, fontSize: 9.5, fontWeight: 700, fill: "var(--text-primary)", children: fmtMonthShort(labels2[survol]) }),
|
||||||
|
disposees.map((f, k) => /* @__PURE__ */ jsxs("g", { children: [
|
||||||
|
/* @__PURE__ */ jsx("rect", { x: bx + 9, y: 26 + k * 13, width: 8, height: 3, rx: 1.5, fill: f.couleur }),
|
||||||
|
/* @__PURE__ */ jsx("text", { x: bx + 22, y: 30 + k * 13, fontSize: 9, fill: "var(--text-secondary)", children: f.titre }),
|
||||||
|
/* @__PURE__ */ jsx(
|
||||||
|
"text",
|
||||||
|
{
|
||||||
|
x: bx + largeur - 9,
|
||||||
|
y: 30 + k * 13,
|
||||||
|
textAnchor: "end",
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
fill: "var(--text-primary)",
|
||||||
|
children: fmt(f.valeurs[survol], f.decimales ?? 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
] }, `bulle-${f.titre}`))
|
||||||
|
] });
|
||||||
|
})(),
|
||||||
|
labels2.map((lab, i) => /* @__PURE__ */ jsx(
|
||||||
|
"rect",
|
||||||
|
{
|
||||||
|
x: x(i) - plotW / (2 * Math.max(1, n - 1)),
|
||||||
|
y: 0,
|
||||||
|
width: plotW / Math.max(1, n - 1),
|
||||||
|
height: H,
|
||||||
|
fill: "transparent",
|
||||||
|
onMouseEnter: () => setSurvol(i)
|
||||||
|
},
|
||||||
|
`survol-${lab}`
|
||||||
|
))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ jsxs("details", { className: "mt-2", children: [
|
||||||
|
/* @__PURE__ */ jsx("summary", { className: "text-[11px] cursor-pointer select-none", style: { color: "var(--text-muted)" }, children: "Voir les valeurs mois par mois" }),
|
||||||
|
/* @__PURE__ */ jsx("div", { className: "mt-1.5 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-[11px] tabular-nums", style: { borderCollapse: "collapse" }, children: [
|
||||||
|
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { style: { color: "var(--text-muted)" }, children: [
|
||||||
|
/* @__PURE__ */ jsx("th", { className: "text-left font-medium py-1 pr-2", children: "Mois" }),
|
||||||
|
disposees.map((f) => /* @__PURE__ */ jsx("th", { className: "text-right font-medium py-1 pl-2", children: f.titre }, `th-${f.titre}`))
|
||||||
|
] }) }),
|
||||||
|
/* @__PURE__ */ jsx("tbody", { children: labels2.map((lab, i) => /* @__PURE__ */ jsxs("tr", { style: { borderTop: "1px solid var(--border)" }, children: [
|
||||||
|
/* @__PURE__ */ jsx("td", { className: "py-1 pr-2", style: { color: "var(--text-secondary)" }, children: fmtMonthShort(lab) }),
|
||||||
|
disposees.map((f) => /* @__PURE__ */ jsx("td", { className: "text-right py-1 pl-2", style: { color: "var(--text-primary)" }, children: fmt(f.valeurs[i], f.decimales ?? 0) }, `td-${f.titre}-${lab}`))
|
||||||
|
] }, `tr-${lab}`)) })
|
||||||
|
] }) })
|
||||||
|
] })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// <stdin>
|
||||||
|
var labels = ["2025-08", "2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07"];
|
||||||
|
var values = [9953, 13258, 6353, 5429, 3741, 6522, 8211, 4437, 4035, 4066, 2936, 4470];
|
||||||
|
var stores = [208, 228, 196, 177, 175, 193, 206, 190, 179, 179, 167, 170];
|
||||||
|
var perStore = values.map((v, i) => stores[i] > 0 ? v / stores[i] : 0);
|
||||||
|
globalThis.__HTML__ = renderToStaticMarkup(
|
||||||
|
React.createElement(NetworkLineChart, { labels, values, stores, perStore })
|
||||||
|
);
|
||||||
@@ -75,6 +75,56 @@ function getQlikNetworkSortValue(value: number | null | undefined, columnId: str
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
// 1. Composant isolé pour la Cellule Gamme (évite le re-render des colonnes)
|
// 1. Composant isolé pour la Cellule Gamme (évite le re-render des colonnes)
|
||||||
|
/**
|
||||||
|
* Cellule dont la valeur se copie d'un clic.
|
||||||
|
*
|
||||||
|
* Extraite de la colonne « Code interne », qui la portait en ligne avec un
|
||||||
|
* `eslint-disable react-hooks/rules-of-hooks` : un `useState` dans le corps d'un
|
||||||
|
* `cell()` de TanStack n'est pas rendu au même endroit d'un rendu à l'autre. Un
|
||||||
|
* vrai composant supprime la dérogation et rend la copie réutilisable — les
|
||||||
|
* références et les gencodes se recopient autant que les codes internes.
|
||||||
|
*
|
||||||
|
* `stopPropagation` est indispensable : sans lui, le clic sélectionne aussi la
|
||||||
|
* ligne.
|
||||||
|
*/
|
||||||
|
const CopiableCell = React.memo(function CopiableCell({ value, titre, className, style }: {
|
||||||
|
value: string | null | undefined;
|
||||||
|
titre: string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const texte = (value ?? "").trim();
|
||||||
|
|
||||||
|
if (!texte) {
|
||||||
|
return <span className={className} style={style}>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(texte);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors min-w-0"
|
||||||
|
title={copied ? "Copié !" : titre}
|
||||||
|
>
|
||||||
|
<span className={cn("truncate", className)} style={style}>{texte}</span>
|
||||||
|
<span className="shrink-0 transition-all duration-200">
|
||||||
|
{copied ? (
|
||||||
|
<Check className="w-3 h-3 text-emerald-500 animate-in zoom-in-50" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 opacity-0 group-hover:opacity-40 hover:!opacity-100" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boolean }) => {
|
const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boolean }) => {
|
||||||
// Abonnement ultra-ciblé : la cellule ne re-render que si SA valeur change
|
// Abonnement ultra-ciblé : la cellule ne re-render que si SA valeur change
|
||||||
const codein = row.codein;
|
const codein = row.codein;
|
||||||
@@ -461,63 +511,40 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
accessorKey: "codein",
|
accessorKey: "codein",
|
||||||
header: "Code interne",
|
header: "Code interne",
|
||||||
size: 90,
|
size: 90,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => (
|
||||||
const value = getValue<string>();
|
<CopiableCell
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
value={getValue<string>()}
|
||||||
const [copied, setCopied] = useState(false);
|
titre="Copier le code interne"
|
||||||
|
className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100"
|
||||||
const handleCopy = (e: React.MouseEvent) => {
|
style={{ color: "var(--text-muted)" }}
|
||||||
e.stopPropagation();
|
/>
|
||||||
navigator.clipboard.writeText(value);
|
),
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors"
|
|
||||||
title="Copier le code interne"
|
|
||||||
>
|
|
||||||
<span className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
<div className="shrink-0 transition-all duration-200">
|
|
||||||
{copied ? (
|
|
||||||
<Check className="w-3 h-3 text-emerald-500 animate-in zoom-in-50" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-3 h-3 opacity-0 group-hover:opacity-40 hover:!opacity-100" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "reference",
|
accessorKey: "reference",
|
||||||
header: "Référence",
|
header: "Référence",
|
||||||
size: 110,
|
size: 110,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => (
|
||||||
const val = getValue<string>();
|
<CopiableCell
|
||||||
return (
|
value={getValue<string>()}
|
||||||
<span className="text-[12px] font-mono opacity-80" style={{ color: "var(--text-secondary)" }}>
|
titre="Copier la référence"
|
||||||
{val || "-"}
|
className="text-[12px] font-mono opacity-80 group-hover:opacity-100"
|
||||||
</span>
|
style={{ color: "var(--text-secondary)" }}
|
||||||
);
|
/>
|
||||||
},
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "gtin",
|
accessorKey: "gtin",
|
||||||
header: "EAN / GTIN",
|
header: "EAN / GTIN",
|
||||||
size: 120,
|
size: 120,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => (
|
||||||
const val = getValue<string>();
|
<CopiableCell
|
||||||
return (
|
value={getValue<string>()}
|
||||||
<span className="text-[12px] font-mono tracking-tight opacity-70" style={{ color: "var(--text-secondary)" }}>
|
titre="Copier l'EAN / GTIN"
|
||||||
{val || "-"}
|
className="text-[12px] font-mono tracking-tight opacity-70 group-hover:opacity-100"
|
||||||
</span>
|
style={{ color: "var(--text-secondary)" }}
|
||||||
);
|
/>
|
||||||
},
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "libelle1",
|
accessorKey: "libelle1",
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function NetworkLineChart({
|
|||||||
perStore?: number[] | null;
|
perStore?: number[] | null;
|
||||||
}) {
|
}) {
|
||||||
const [survol, setSurvol] = useState<number | null>(null);
|
const [survol, setSurvol] = useState<number | null>(null);
|
||||||
|
const [vue, setVue] = useState<"graphique" | "tableau">("graphique");
|
||||||
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);
|
||||||
@@ -160,11 +161,37 @@ export function NetworkLineChart({
|
|||||||
const basPlots = curseur - ecart;
|
const basPlots = curseur - ecart;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 w-full overflow-x-auto">
|
<div className="mt-3 w-full">
|
||||||
|
{/* Bascule : la même surface sert au dessin ou aux chiffres. */}
|
||||||
|
<div className="flex justify-end mb-1.5">
|
||||||
|
<div className="inline-flex rounded-lg p-0.5" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
|
||||||
|
{(["graphique", "tableau"] as const).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVue(v)}
|
||||||
|
className="px-2.5 py-0.5 text-[10px] font-semibold rounded-md transition-colors capitalize"
|
||||||
|
style={vue === v
|
||||||
|
? { background: "var(--bg-surface)", color: "var(--text-primary)" }
|
||||||
|
: { color: "var(--text-muted)" }}
|
||||||
|
aria-pressed={vue === v}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{vue === "graphique" ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
className="w-full"
|
width="100%"
|
||||||
style={{ minWidth: 420 }}
|
height={H}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
// `display:block` : un SVG en ligne traîne l'espace sous la ligne de
|
||||||
|
// base (6 px mesurés), et la carte changeait de hauteur en basculant
|
||||||
|
// vers le tableau.
|
||||||
|
style={{ minWidth: 460, display: "block" }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={"Ventes réseau mensuelles : " + disposees.map((f) => f.titre).join(", ")}
|
aria-label={"Ventes réseau mensuelles : " + disposees.map((f) => f.titre).join(", ")}
|
||||||
onMouseLeave={() => setSurvol(null)}
|
onMouseLeave={() => setSurvol(null)}
|
||||||
@@ -266,6 +293,45 @@ export function NetworkLineChart({
|
|||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Infobulle DANS le dessin.
|
||||||
|
Elle vivait sous le SVG, en HTML : selon la longueur des valeurs elle
|
||||||
|
passait sur deux lignes et la carte changeait de hauteur en cours de
|
||||||
|
survol. Ici la géométrie est figée par le viewBox — la carte ne bouge
|
||||||
|
plus jamais. `pointerEvents=none` laisse passer le survol vers les
|
||||||
|
cibles en dessous. */}
|
||||||
|
{survol != null && (() => {
|
||||||
|
const largeur = 158;
|
||||||
|
const hauteur = 16 + disposees.length * 13 + 8;
|
||||||
|
// La bulle bascule à gauche du réticule quand elle déborderait à droite.
|
||||||
|
const gauche = x(survol) + 10 + largeur > W - 4;
|
||||||
|
const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10;
|
||||||
|
return (
|
||||||
|
<g pointerEvents="none">
|
||||||
|
<rect
|
||||||
|
x={bx} y={4} width={largeur} height={hauteur} rx={8}
|
||||||
|
fill="var(--bg-elevated)" stroke="var(--border)" strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<text x={bx + 9} y={17} fontSize={9.5} fontWeight={700} fill="var(--text-primary)">
|
||||||
|
{fmtMonthShort(labels[survol])}
|
||||||
|
</text>
|
||||||
|
{disposees.map((f, k) => (
|
||||||
|
<g key={`bulle-${f.titre}`}>
|
||||||
|
<rect x={bx + 9} y={26 + k * 13} width={8} height={3} rx={1.5} fill={f.couleur} />
|
||||||
|
<text x={bx + 22} y={30 + k * 13} fontSize={9} fill="var(--text-secondary)">
|
||||||
|
{f.titre}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={bx + largeur - 9} y={30 + k * 13}
|
||||||
|
textAnchor="end" fontSize={9} fontWeight={700} fill="var(--text-primary)"
|
||||||
|
>
|
||||||
|
{fmt(f.valeurs[survol], f.decimales ?? 0)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Zones de survol : larges cibles, une par mois */}
|
{/* Zones de survol : larges cibles, une par mois */}
|
||||||
{labels.map((lab, i) => (
|
{labels.map((lab, i) => (
|
||||||
<rect
|
<rect
|
||||||
@@ -279,58 +345,42 @@ export function NetworkLineChart({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
|
) : (
|
||||||
{/* Vue tableau : l'équivalent accessible, et le recours quand une teinte
|
/* Vue tableau : même panneau, MÊME HAUTEUR.
|
||||||
passe sous le seuil de contraste. Aucune valeur n'est réservée au survol. */}
|
Un dépliant aurait fait grandir la carte à l'ouverture — exactement ce
|
||||||
<details className="mt-2">
|
qu'on venait de corriger sur le survol. Le tableau occupe donc la place
|
||||||
<summary className="text-[11px] cursor-pointer select-none" style={{ color: "var(--text-muted)" }}>
|
du graphique et défile à l'intérieur : la carte garde sa taille, et
|
||||||
Voir les valeurs mois par mois
|
aucune valeur n'est réservée au survol. */
|
||||||
</summary>
|
<div style={{ height: H }} className="overflow-y-auto overflow-x-auto">
|
||||||
<div className="mt-1.5 overflow-x-auto">
|
<table className="w-full text-[11px] tabular-nums" style={{ borderCollapse: "collapse" }}>
|
||||||
<table className="w-full text-[11px] tabular-nums" style={{ borderCollapse: "collapse" }}>
|
<thead className="sticky top-0" style={{ background: "var(--bg-surface)" }}>
|
||||||
<thead>
|
<tr style={{ color: "var(--text-muted)" }}>
|
||||||
<tr style={{ color: "var(--text-muted)" }}>
|
<th className="text-left font-medium py-1 pr-2">Mois</th>
|
||||||
<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 whitespace-nowrap">
|
||||||
|
{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 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{fmtMonthShort(lab)}
|
||||||
|
</td>
|
||||||
{disposees.map((f) => (
|
{disposees.map((f) => (
|
||||||
<th key={`th-${f.titre}`} className="text-right font-medium py-1 pl-2">{f.titre}</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{labels.map((lab, i) => (
|
</table>
|
||||||
<tr key={`tr-${lab}`} style={{ borderTop: "1px solid var(--border)" }}>
|
</div>
|
||||||
<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