mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 05:28:11 +02:00
Carte tendance : desencombrer le graphique, il etait illisible
La densite etait le vrai probleme. La version precedente imprimait les 24 valeurs (12 quantites + 12 magasins) et titrait chaque bande DANS le SVG. Resultat sur ecran : les titres passaient par-dessus les courbes, les nombres des extremites etaient coupes par le bord du cadre, et les etiquettes des magasins se collaient au trait pointille. - Les titres sortent du dessin : legende HTML au-dessus, plus aucun recouvrement possible quelle que soit la forme des courbes. - La bande des magasins ne porte plus que sa DERNIERE valeur ; son niveau se lit a la forme et au maximum rappele dans la legende. - Les etiquettes des bords sont ancrees vers l'interieur (start / end au lieu de middle) : plus rien n'est tronque. - Chaque mois expose une infobulle native avec les valeurs exactes, ce qui rend inutile d'imprimer tous les nombres. - Cadre elargi (520 de large, marge haute pour les etiquettes du sommet) et modale passee en max-w-xl. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
2 files changed
+86
-43
No files matched your search
@@ -203,7 +203,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
|
|||||||
// hausse due à l'élargissement de la diffusion d'une vraie accélération.
|
// hausse due à l'élargissement de la diffusion d'une vraie accélération.
|
||||||
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
|
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
|
||||||
return (
|
return (
|
||||||
<DialogContent className="max-w-md" 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>
|
||||||
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
<DialogTitle className="text-base leading-snug pr-6" style={{ color: "var(--text-primary)" }}>
|
||||||
{row.libelle1}
|
{row.libelle1}
|
||||||
|
|||||||
@@ -81,10 +81,15 @@ export function storesColorFor(couleurQuantites: string): string {
|
|||||||
* passe AU-DESSUS de celle des quantités, ce qui se lit spontanément comme « il
|
* passe AU-DESSUS de celle des quantités, ce qui se lit spontanément comme « il
|
||||||
* y a plus de magasins que de ventes » — un contresens.
|
* y a plus de magasins que de ventes » — un contresens.
|
||||||
*
|
*
|
||||||
* Les deux séries sont donc tracées dans deux bandes distinctes, alignées sur le
|
* ⚠️ LA DENSITÉ EST LE VRAI ENNEMI. Une première version imprimait les 24
|
||||||
* même axe des mois. On compare ce qui est comparable — la forme de l'une avec
|
* valeurs (12 quantités + 12 magasins) et titrait chaque bande dans le SVG :
|
||||||
* la forme de l'autre, mois par mois — sans jamais suggérer un rapport de
|
* titres par-dessus les courbes, nombres des extrémités coupés par le bord,
|
||||||
* grandeur entre elles.
|
* étiquettes des magasins collées au trait. Illisible. D'où :
|
||||||
|
* - les titres sortent du dessin (HTML au-dessus, plus aucun recouvrement) ;
|
||||||
|
* - la bande des magasins ne porte plus que sa dernière valeur, son niveau se
|
||||||
|
* lisant à la forme et au maximum rappelé dans la légende ;
|
||||||
|
* - les étiquettes des bords sont ancrées vers l'intérieur, jamais tronquées ;
|
||||||
|
* - chaque mois expose une infobulle native avec les valeurs exactes.
|
||||||
*
|
*
|
||||||
* Lecture : quantités qui montent avec les magasins = élargissement de la
|
* Lecture : quantités qui montent avec les magasins = élargissement de la
|
||||||
* diffusion ; quantités qui montent à magasins constants = vraie accélération
|
* diffusion ; quantités qui montent à magasins constants = vraie accélération
|
||||||
@@ -105,15 +110,14 @@ export function NetworkLineChart({
|
|||||||
const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0;
|
const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0;
|
||||||
const n = values.length;
|
const n = values.length;
|
||||||
|
|
||||||
const W = 460;
|
const W = 520;
|
||||||
const padL = 10, padR = 10, padB = 22;
|
const padL = 14, padR = 14;
|
||||||
const titreH = 12; // hauteur réservée au titre d'une bande
|
const hautQ = 16; // marge haute : place aux étiquettes du sommet
|
||||||
const bandeQ = 120; // bande des quantités
|
const bandeQ = 116;
|
||||||
const bandeM = avecMagasins ? 46 : 0; // bande des magasins
|
const ecart = avecMagasins ? 12 : 0;
|
||||||
const ecart = avecMagasins ? 18 : 0;
|
const bandeM = avecMagasins ? 44 : 0;
|
||||||
const hautQ = titreH + 10; // sommet de la bande des quantités
|
const moisH = 18;
|
||||||
const hautM = hautQ + bandeQ + ecart + titreH;
|
const H = hautQ + bandeQ + ecart + bandeM + moisH;
|
||||||
const H = hautQ + bandeQ + ecart + (avecMagasins ? titreH + bandeM : 0) + padB;
|
|
||||||
const plotW = W - padL - padR;
|
const plotW = W - padL - padR;
|
||||||
|
|
||||||
// Les quantités Qlik peuvent être NÉGATIVES (retours supérieurs aux ventes
|
// Les quantités Qlik peuvent être NÉGATIVES (retours supérieurs aux ventes
|
||||||
@@ -126,29 +130,49 @@ export function NetworkLineChart({
|
|||||||
|
|
||||||
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);
|
||||||
const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ;
|
const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ;
|
||||||
const yM = (v: number) => hautM + bandeM - (v / maxM) * bandeM;
|
const yM = (v: number) => hautQ + bandeQ + ecart + bandeM - (v / maxM) * bandeM;
|
||||||
const zero = y(0);
|
const zero = y(0);
|
||||||
|
|
||||||
|
/** Aux extrémités, on ancre l'étiquette vers l'intérieur : sinon elle sort du cadre. */
|
||||||
|
const ancrage = (i: number): "start" | "middle" | "end" =>
|
||||||
|
i === 0 ? "start" : i === n - 1 ? "end" : "middle";
|
||||||
|
|
||||||
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||||
const areaPts = `${padL},${zero.toFixed(1)} ${linePts} ${(padL + plotW).toFixed(1)},${zero.toFixed(1)}`;
|
const areaPts = `${padL},${zero.toFixed(1)} ${linePts} ${(padL + plotW).toFixed(1)},${zero.toFixed(1)}`;
|
||||||
const storesColor = storesColorFor(color);
|
const storesColor = storesColorFor(color);
|
||||||
const storePts = avecMagasins
|
const storePts = avecMagasins
|
||||||
? stores!.map((v, i) => `${x(i).toFixed(1)},${yM(v).toFixed(1)}`).join(" ")
|
? stores!.map((v, i) => `${x(i).toFixed(1)},${yM(v).toFixed(1)}`).join(" ")
|
||||||
: "";
|
: "";
|
||||||
|
const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR");
|
||||||
|
|
||||||
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 rien ne recouvre les courbes. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mb-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: color }} />
|
||||||
|
Quantités vendues
|
||||||
|
<span className="tabular-nums" style={{ color: "var(--text-muted)" }}>· max {fmt(maxQ)}</span>
|
||||||
|
</span>
|
||||||
|
{avecMagasins && (
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
<span
|
||||||
|
className="inline-block w-3 h-0 rounded-full"
|
||||||
|
style={{ borderTop: `3px dashed ${storesColor}` }}
|
||||||
|
/>
|
||||||
|
Magasins vendeurs
|
||||||
|
<span className="tabular-nums" style={{ color: "var(--text-muted)" }}>· max {fmt(maxM)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ minWidth: 380 }}
|
style={{ minWidth: 420 }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={avecMagasins ? "Ventes réseau mensuelles et nombre de magasins vendeurs" : "Ventes réseau mensuelles"}
|
aria-label={avecMagasins ? "Ventes réseau mensuelles et nombre de magasins vendeurs" : "Ventes réseau mensuelles"}
|
||||||
>
|
>
|
||||||
{/* ─── Bande 1 : quantités ─────────────────────────────── */}
|
{/* ─── Bande 1 : quantités ─────────────────────────────── */}
|
||||||
<text x={padL} y={titreH} fontSize={9} fontWeight={700} fill={color}>
|
|
||||||
QUANTITÉS VENDUES
|
|
||||||
</text>
|
|
||||||
<line x1={padL} y1={zero} x2={padL + plotW} y2={zero} stroke="var(--border)" strokeWidth={1} />
|
<line x1={padL} y1={zero} x2={padL + plotW} y2={zero} stroke="var(--border)" strokeWidth={1} />
|
||||||
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
|
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
|
||||||
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
||||||
@@ -158,12 +182,12 @@ export function NetworkLineChart({
|
|||||||
<text
|
<text
|
||||||
x={x(i)}
|
x={x(i)}
|
||||||
y={v >= 0 ? y(v) - 6 : y(v) + 12}
|
y={v >= 0 ? y(v) - 6 : y(v) + 12}
|
||||||
textAnchor="middle"
|
textAnchor={ancrage(i)}
|
||||||
fontSize={9}
|
fontSize={8.5}
|
||||||
fontWeight={700}
|
fontWeight={700}
|
||||||
fill="var(--text-primary)"
|
fill="var(--text-primary)"
|
||||||
>
|
>
|
||||||
{Math.round(v).toLocaleString("fr-FR")}
|
{fmt(v)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
@@ -171,15 +195,11 @@ export function NetworkLineChart({
|
|||||||
{/* ─── Bande 2 : magasins vendeurs ─────────────────────── */}
|
{/* ─── Bande 2 : magasins vendeurs ─────────────────────── */}
|
||||||
{avecMagasins && (
|
{avecMagasins && (
|
||||||
<>
|
<>
|
||||||
<text x={padL} y={hautM - 3} fontSize={9} fontWeight={700} fill={storesColor}>
|
|
||||||
MAGASINS VENDEURS
|
|
||||||
<tspan fontWeight={400} fill="var(--text-muted)"> · {maxM.toLocaleString("fr-FR")} au maximum</tspan>
|
|
||||||
</text>
|
|
||||||
<line
|
<line
|
||||||
x1={padL}
|
x1={padL}
|
||||||
y1={hautM + bandeM}
|
y1={hautQ + bandeQ + ecart + bandeM}
|
||||||
x2={padL + plotW}
|
x2={padL + plotW}
|
||||||
y2={hautM + bandeM}
|
y2={hautQ + bandeQ + ecart + bandeM}
|
||||||
stroke="var(--border)"
|
stroke="var(--border)"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
@@ -195,30 +215,53 @@ export function NetworkLineChart({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{stores!.map((v, i) => (
|
{stores!.map((v, i) => (
|
||||||
<g key={`mag-${labels[i]}`}>
|
// Point évidé : lisible même sans couleur (impression N&B, daltonisme).
|
||||||
{/* point évidé : lisible même sans couleur */}
|
<circle
|
||||||
<circle
|
key={`mag-${labels[i]}`}
|
||||||
cx={x(i)}
|
cx={x(i)}
|
||||||
cy={yM(v)}
|
cy={yM(v)}
|
||||||
r={2.4}
|
r={2.2}
|
||||||
fill="var(--bg-surface)"
|
fill="var(--bg-surface)"
|
||||||
stroke={storesColor}
|
stroke={storesColor}
|
||||||
strokeWidth={1.4}
|
strokeWidth={1.3}
|
||||||
/>
|
/>
|
||||||
<text x={x(i)} y={yM(v) - 5} textAnchor="middle" fontSize={8} fontWeight={600} fill={storesColor}>
|
|
||||||
{Math.round(v).toLocaleString("fr-FR")}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
))}
|
))}
|
||||||
|
{/* Seule la dernière valeur est écrite : le reste se lit à la forme. */}
|
||||||
|
<text
|
||||||
|
x={x(n - 1)}
|
||||||
|
y={yM(stores![n - 1]) - 6}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize={8.5}
|
||||||
|
fontWeight={700}
|
||||||
|
fill={storesColor}
|
||||||
|
>
|
||||||
|
{fmt(stores![n - 1])} mag.
|
||||||
|
</text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Axe des mois, commun aux deux bandes ────────────── */}
|
{/* ─── Axe des mois, commun aux deux bandes ────────────── */}
|
||||||
{labels.map((lab, i) => (
|
{labels.map((lab, i) => (
|
||||||
<text key={`mois-${lab}`} x={x(i)} y={H - 7} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
|
<text key={`mois-${lab}`} x={x(i)} y={H - 5} textAnchor={ancrage(i)} fontSize={8} fill="var(--text-muted)">
|
||||||
{fmtMonthShort(lab)}
|
{fmtMonthShort(lab)}
|
||||||
</text>
|
</text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Zones de survol : valeurs exactes du mois, sans encombrer le dessin. */}
|
||||||
|
{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}
|
||||||
|
fill="transparent"
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{`${fmtMonthShort(lab)} · ${fmt(values[i])} vendues${avecMagasins ? ` · ${fmt(stores![i])} magasins` : ""}`}
|
||||||
|
</title>
|
||||||
|
</rect>
|
||||||
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user