Skip to content

Commit 7549ad7

Browse files
committed
feat(admin): K10 chart + toggle checkboxes + accessible table
Three client components for the multi-year gap trend section: - `<MultiYearGapChart>` — Recharts LineChart with a DSFR palette, one line per segment, custom tooltip showing sample size alongside the gap. Lines whose segment is in `hiddenSegments` are filtered out before render (not just hidden via CSS) so the chart reflows. - `<GapChartSeriesToggle>` — DSFR fieldset of inline checkboxes, one per segment, with a `fr-hint-text` in the legend. Preferred over clickable Recharts legends because DSFR checkboxes carry better keyboard and screen-reader semantics. - `<MultiYearGapTable>` — RGAA-mandatory accessible alternative, wrapped in a `<details>` disclosure, same numbers as the chart (gap + sample size per (segment, year) pair).
1 parent 3fcdb85 commit 7549ad7

7 files changed

Lines changed: 519 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"use client";
2+
3+
import type { ChangeEvent } from "react";
4+
5+
type Props = {
6+
/**
7+
* Full, ordered list of series on the chart — in the exact order the chart
8+
* renders them, so the checkbox order matches the legend.
9+
*/
10+
segments: readonly string[];
11+
/** Segment names currently hidden. */
12+
hiddenSegments: ReadonlySet<string>;
13+
/** Called with the next hidden-set when the user toggles a checkbox. */
14+
onChange: (next: ReadonlySet<string>) => void;
15+
/** Stable id prefix — ensures unique ids when multiple charts coexist. */
16+
idPrefix?: string;
17+
};
18+
19+
export function GapChartSeriesToggle({
20+
segments,
21+
hiddenSegments,
22+
onChange,
23+
idPrefix = "gap-chart-series",
24+
}: Props) {
25+
const handleChange =
26+
(segment: string) => (event: ChangeEvent<HTMLInputElement>) => {
27+
const next = new Set(hiddenSegments);
28+
if (event.target.checked) {
29+
next.delete(segment);
30+
} else {
31+
next.add(segment);
32+
}
33+
onChange(next);
34+
};
35+
36+
return (
37+
<fieldset
38+
aria-describedby={`${idPrefix}-hint`}
39+
className="fr-fieldset"
40+
id={`${idPrefix}-fieldset`}
41+
>
42+
<legend
43+
className="fr-fieldset__legend fr-fieldset__legend--regular"
44+
id={`${idPrefix}-legend`}
45+
>
46+
Séries affichées
47+
<span className="fr-hint-text" id={`${idPrefix}-hint`}>
48+
Décochez une série pour la masquer du graphique.
49+
</span>
50+
</legend>
51+
{segments.map((segment) => {
52+
const checkboxId = `${idPrefix}-${segment}`;
53+
const checked = !hiddenSegments.has(segment);
54+
return (
55+
<div
56+
className="fr-fieldset__element fr-fieldset__element--inline"
57+
key={segment}
58+
>
59+
<div className="fr-checkbox-group">
60+
<input
61+
checked={checked}
62+
id={checkboxId}
63+
name={checkboxId}
64+
onChange={handleChange(segment)}
65+
type="checkbox"
66+
/>
67+
<label className="fr-label" htmlFor={checkboxId}>
68+
{segment}
69+
</label>
70+
</div>
71+
</div>
72+
);
73+
})}
74+
</fieldset>
75+
);
76+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.chartWrapper {
2+
width: 100%;
3+
height: 360px;
4+
}
5+
6+
.tooltipList {
7+
margin: 0;
8+
padding: 0;
9+
list-style: none;
10+
}
11+
12+
.tooltipItem {
13+
font-size: 0.875rem;
14+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"use client";
2+
3+
import {
4+
CartesianGrid,
5+
Line,
6+
LineChart,
7+
ResponsiveContainer,
8+
Tooltip,
9+
XAxis,
10+
YAxis,
11+
} from "recharts";
12+
13+
import { formatGap } from "~/modules/domain";
14+
15+
import styles from "./MultiYearGapChart.module.scss";
16+
import type { MultiYearGapTrendSeries } from "./types";
17+
18+
type Props = {
19+
series: MultiYearGapTrendSeries[];
20+
/**
21+
* Names of the series currently hidden by the toggle checkboxes. The chart
22+
* still plots its X-axis but does not render the corresponding `<Line>`.
23+
*/
24+
hiddenSegments: ReadonlySet<string>;
25+
};
26+
27+
type TooltipEntry = {
28+
name?: string | number;
29+
value?: number;
30+
color?: string;
31+
};
32+
33+
type TrendTooltipProps = {
34+
active?: boolean;
35+
payload?: TooltipEntry[];
36+
label?: string | number;
37+
sampleSizes: Record<string, Record<number, number>>;
38+
};
39+
40+
type MergedPoint = {
41+
year: number;
42+
[segment: string]: number | null;
43+
};
44+
45+
/**
46+
* DSFR color cycle — one per series, taken from design-system tokens so the
47+
* tiles, badges and the chart stay visually consistent. The order is
48+
* deterministic so the same segment keeps the same color across re-renders.
49+
*/
50+
const SERIES_COLORS = [
51+
"var(--background-action-high-blue-france)",
52+
"var(--background-action-high-green-bourgeon)",
53+
"var(--background-action-high-orange-terre-battue)",
54+
"var(--background-action-high-purple-glycine)",
55+
"var(--background-action-high-pink-tuile)",
56+
"var(--text-mention-grey)",
57+
];
58+
59+
export function colorForSegment(index: number): string {
60+
return SERIES_COLORS[index % SERIES_COLORS.length] ?? SERIES_COLORS[0] ?? "";
61+
}
62+
63+
/**
64+
* Interleave all series into a single row-per-year dataset so Recharts can
65+
* plot one line per segment on a shared year axis. Missing points stay null.
66+
*/
67+
function mergeSeries(series: MultiYearGapTrendSeries[]): MergedPoint[] {
68+
const byYear = new Map<number, MergedPoint>();
69+
for (const { segment, points } of series) {
70+
for (const { year, avgGap } of points) {
71+
const existing = byYear.get(year) ?? { year };
72+
existing[segment] = avgGap;
73+
byYear.set(year, existing);
74+
}
75+
}
76+
return Array.from(byYear.values()).sort((a, b) => a.year - b.year);
77+
}
78+
79+
function TrendTooltip({
80+
active,
81+
payload,
82+
label,
83+
sampleSizes,
84+
}: TrendTooltipProps) {
85+
if (
86+
!active ||
87+
!payload ||
88+
payload.length === 0 ||
89+
typeof label !== "number"
90+
) {
91+
return null;
92+
}
93+
return (
94+
<div className="fr-p-2w fr-background-alt--grey">
95+
<p className="fr-text--sm fr-text--bold fr-mb-1w">Année {label}</p>
96+
<ul className={styles.tooltipList}>
97+
{payload.map((entry) => {
98+
if (entry.value == null) return null;
99+
const segment = String(entry.name ?? "");
100+
const size = sampleSizes[segment]?.[label] ?? 0;
101+
return (
102+
<li className={styles.tooltipItem} key={segment}>
103+
{segment} : écart moyen {formatGap(entry.value)} (sur{" "}
104+
{size.toLocaleString("fr-FR")} déclarations)
105+
</li>
106+
);
107+
})}
108+
</ul>
109+
</div>
110+
);
111+
}
112+
113+
export function MultiYearGapChart({ series, hiddenSegments }: Props) {
114+
const data = mergeSeries(series);
115+
116+
if (data.length === 0) {
117+
return (
118+
<p aria-live="polite" className="fr-text--sm fr-text-mention--grey">
119+
Aucune donnée pour ces filtres.
120+
</p>
121+
);
122+
}
123+
124+
const sampleSizes: Record<string, Record<number, number>> = {};
125+
for (const { segment, points } of series) {
126+
sampleSizes[segment] = {};
127+
for (const { year, sampleSize } of points) {
128+
const current = sampleSizes[segment];
129+
if (current) current[year] = sampleSize;
130+
}
131+
}
132+
133+
const visibleSeries = series.filter((s) => !hiddenSegments.has(s.segment));
134+
135+
return (
136+
<figure className={styles.chartWrapper}>
137+
<figcaption className="fr-sr-only">
138+
Courbe d'évolution annuelle de l'écart moyen de rémunération. Les
139+
données équivalentes sont disponibles dans le tableau ci-dessous.
140+
</figcaption>
141+
<ResponsiveContainer>
142+
<LineChart data={data}>
143+
<CartesianGrid strokeDasharray="3 3" />
144+
<XAxis
145+
allowDecimals={false}
146+
dataKey="year"
147+
tickFormatter={(value: number) => String(value)}
148+
/>
149+
<YAxis tickFormatter={(value: number) => `${value.toFixed(1)} %`} />
150+
<Tooltip content={<TrendTooltip sampleSizes={sampleSizes} />} />
151+
{visibleSeries.map(({ segment }) => {
152+
const originalIndex = series.findIndex(
153+
(s) => s.segment === segment,
154+
);
155+
return (
156+
<Line
157+
connectNulls
158+
dataKey={segment}
159+
dot
160+
key={segment}
161+
name={segment}
162+
stroke={colorForSegment(originalIndex)}
163+
strokeWidth={2}
164+
type="monotone"
165+
/>
166+
);
167+
})}
168+
</LineChart>
169+
</ResponsiveContainer>
170+
</figure>
171+
);
172+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { formatCount, formatGap } from "~/modules/domain";
2+
3+
import type { MultiYearGapTrendSeries } from "./types";
4+
5+
type Props = {
6+
series: MultiYearGapTrendSeries[];
7+
};
8+
9+
/**
10+
* Accessible alternative to `<MultiYearGapChart>` — renders the same
11+
* (segment × year) data as a DSFR table. Required for RGAA: a Recharts SVG
12+
* is not readable by assistive tech, so we surface the numbers in a
13+
* `<details>` toggle that lives in the DOM at all times.
14+
*/
15+
export function MultiYearGapTable({ series }: Props) {
16+
if (series.length === 0) return null;
17+
18+
const years = Array.from(
19+
new Set(series.flatMap(({ points }) => points.map(({ year }) => year))),
20+
).sort((a, b) => a - b);
21+
22+
const byYearBySegment = new Map<
23+
string,
24+
Map<number, { avgGap: number | null; sampleSize: number }>
25+
>();
26+
for (const { segment, points } of series) {
27+
const inner = new Map<
28+
number,
29+
{ avgGap: number | null; sampleSize: number }
30+
>();
31+
for (const { year, avgGap, sampleSize } of points) {
32+
inner.set(year, { avgGap, sampleSize });
33+
}
34+
byYearBySegment.set(segment, inner);
35+
}
36+
37+
return (
38+
<details className="fr-mt-3w">
39+
<summary className="fr-text--sm">
40+
Consulter les données du graphique sous forme de tableau
41+
</summary>
42+
<div className="fr-table fr-table--bordered fr-mt-2w">
43+
<div className="fr-table__wrapper">
44+
<div className="fr-table__container">
45+
<div className="fr-table__content">
46+
<table>
47+
<caption className="fr-sr-only">
48+
Écart moyen de rémunération par segment et par année.
49+
</caption>
50+
<thead>
51+
<tr>
52+
<th scope="col">Segment</th>
53+
{years.map((year) => (
54+
<th key={year} scope="col">
55+
{year}
56+
</th>
57+
))}
58+
</tr>
59+
</thead>
60+
<tbody>
61+
{series.map(({ segment }) => (
62+
<tr key={segment}>
63+
<th scope="row">{segment}</th>
64+
{years.map((year) => {
65+
const cell = byYearBySegment.get(segment)?.get(year);
66+
if (!cell || cell.avgGap === null) {
67+
return <td key={year}></td>;
68+
}
69+
return (
70+
<td key={year}>
71+
{formatGap(cell.avgGap)} (sur{" "}
72+
{formatCount(cell.sampleSize)})
73+
</td>
74+
);
75+
})}
76+
</tr>
77+
))}
78+
</tbody>
79+
</table>
80+
</div>
81+
</div>
82+
</div>
83+
</div>
84+
</details>
85+
);
86+
}

0 commit comments

Comments
 (0)