Commits (2)
'use client'
import { pie, arc } from "d3-shape";
import { useEffect, useMemo, useState } from "react";
import {taxes} from '@/data/taxes';
import { taxeStats } from "@/type/TaxeStats";
// Types simplifiés pour la clarté
const years = [2018, 2019, 2020, 2021, 2022, 2023];
const COLORS = ["#2563eb", "#16a34a", "#ea580c", "#7c3aed", "#06b6d4", "#f43f5e"];
export default function PieChart() {
const [taxe, setTaxe] = useState("cves");
const [selectedYear, setSelectedYear] = useState<number>(2022);
const [hoveredRegion, setHoveredRegion] = useState<taxeStats | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [data, setData] = useState<taxeStats[]>([]);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const res = await fetch(`https://localhost/${taxe}/stats?annee=${selectedYear}&groupBy=region&metric=montant`);
if (!res.ok) throw new Error("Erreur API");
const apiData = await res.json();
// CORRECTION : On s'assure que la clé correspond à "region" utilisée plus bas
const formatted: taxeStats[] = apiData.member.map((d: any) => ({
region: d.label || d.region || "Inconnu",
year: selectedYear,
value: Number(d.value),
}));
setData(formatted);
} catch (err) {
console.error("Erreur fetch data:", err);
setData([]);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [taxe, selectedYear]);
// 1. Préparation des données et calcul du total
const { processedData, totalSum } = useMemo(() => {
const sum = data.reduce((acc, curr) => acc + curr.value, 0);
return { processedData: data, totalSum: sum };
}, [data]);
// 2. Générateurs D3
const pieGenerator = pie<taxeStats>()
.value(d => d.value)
.sort(null); // Garde l'ordre original
const arcGenerator = arc<any>()
.innerRadius(120) // Transformé en Donut pour plus de modernité
.outerRadius(240)
.cornerRadius(4)
.padAngle(0.02);
const arcs = useMemo(() => pieGenerator(processedData), [processedData]);
return (
<section className="w-full max-w-5xl bg-white rounded-xl shadow-sm p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-800">Impôt collecté par région</h2>
{isLoading && <span className="text-sm text-blue-500 animate-pulse">Chargement...</span>}
</div>
<div className="flex gap-4 mb-8">
<select value={taxe} onChange={e => setTaxe(e.target.value)} className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500m"
>
{taxes.map(t => <option key={t.route} value={t.route}>{t.label}</option>)}
</select>
<select value={selectedYear} onChange={e => setSelectedYear(Number(e.target.value))} className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500m"
>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
<div className="md:col-span-2 flex justify-center relative">
<svg width={400} height={400} viewBox="-200 -200 400 400" className="overflow-visible">
<g>
{arcs.map((d, i) => {
const isActive = hoveredRegion === null || hoveredRegion.region === d.data.region;
const percentage = ((d.data.value / totalSum) * 100).toFixed(1);
return (
<path
key={`arc-${d.data.region}`}
d={arcGenerator(d)!}
fill={COLORS[i % COLORS.length]}
opacity={isActive ? 1 : 0.3}
onMouseEnter={() => setHoveredRegion(d.data)}
onMouseLeave={() => setHoveredRegion(null)}
className="transition-all duration-300 cursor-pointer outline-none"
/>
);
})}
</g>
{/* Centre du Diagramme */}
{hoveredRegion && (
<text textAnchor="middle" className="text-lg fill-gray-700">
<tspan x="0" dy="0">
{hoveredRegion.region}
</tspan>
<tspan x="0" dy="1.2em">
{Math.round(hoveredRegion.value).toLocaleString()}
</tspan>
<tspan x="0" dy="1.2em">
{((hoveredRegion.value / totalSum) * 100).toFixed(1)}%
</tspan>
</text>
)}
</svg>
</div>
<div className="md:col-span-3 mt-4">
<div className="mt-6 flex flex-wrap gap-4 justify-center custom-scrollbar">
{arcs.map((d, i) => {
const percentage = totalSum > 0 ? ((d.data.value / totalSum) * 100).toFixed(1) : 0;
const isActive = hoveredRegion === null || hoveredRegion.region === d.data.region;
return (
<div
key={`legend-${d.data.region}`}
onMouseEnter={() => setHoveredRegion(d.data)}
onMouseLeave={() => setHoveredRegion(null)}
className={` flex items-center gap-2 p-2 rounded-md border transition-all ${isActive
? "bg-gray-50 shadow-sm scale-[1.02]"
: "border-transparent opacity-50 grayscale-[0.5]"
}`}>
<span
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: COLORS[i % COLORS.length] }}
/>
<div className="flex flex-col min-w-0">
<span className="text-xs font-bold text-gray-800 truncate">
{d.data.region}
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-600 font-semibold">{percentage}%</span>
<span className="text-[10px] text-gray-400 truncate">
{Math.round(d.data.value).toLocaleString()}
</span>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</section>
);
}
\ No newline at end of file
"use client";
/*
TODO :
- séparer svg du code
*/
import { useEffect, useMemo, useState } from "react";
import * as d3 from "d3";
import {taxes} from '@/data/taxes';
import { taxeStats } from "@/type/TaxeStats";
export default function Temporelle() {
const [taxe, setTaxe] = useState("cves");
const allYears = [2018, 2019, 2020, 2021, 2022];
const [minYear, setMinYear] = useState<number>(2018);
const [maxYear, setMaxYear] = useState<number>(2022);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [data, setData] = useState<taxeStats[]>([]);
const [activeRegion, setActiveRegion] = useState<string | null>(null);
const width = 900;
const height = 600;
const margin = { top: 40, right: 30, bottom: 40, left: 60 };
useEffect(() => {
if (minYear > maxYear) {
setMaxYear(minYear);
}
}, [minYear]);
useEffect(() => {
if (!taxe) return;
const fetchData = async () => {
setIsLoading(true);
console.log("Fetching data for", taxe, minYear, maxYear);
try {
const results: taxeStats[] = [];
for (let year = minYear; year <= maxYear; year++) {
const res = await fetch(
`https://localhost/${taxe}/stats?annee=${year}&groupBy=region&metric=taux`
);
if (!res.ok) {
throw new Error(`Erreur API année ${year}`);
}
const apiData: any = await res.json();
console.log(`Année ${year}`, apiData.member);
apiData.member.forEach((d: any) => {
results.push({
region: d.label,
year,
value: d.value,
});
});
}
setData(results);
} catch (err) {
console.error("Erreur fetch:", err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [taxe, minYear, maxYear]);
/**
* Filtre Data
*/
const filteredData = useMemo(() => {
return data.filter(d =>
d.year >= minYear &&
d.year <= maxYear
);
}, [data, minYear, maxYear]);
const groupedData = useMemo(() => {
return d3.group(filteredData, d => d.region);
}, [filteredData]);
// logique années
const startYears = useMemo(() => {
return allYears.filter(y => y <= maxYear);
}, [maxYear]);
const endYears = useMemo(() => {
return allYears.filter(y => y >= minYear);
}, [minYear]);
const yearsRange = useMemo(() => {
return d3.range(minYear, maxYear + 1);
}, [minYear, maxYear]);
const xScale = useMemo(() => {
return d3.scalePoint<number>()
.domain(yearsRange)
.range([margin.left, width - margin.right]);
}, [yearsRange]);
const xTicks = yearsRange;
const yMax = d3.max(data, d => d.value) ?? 0;
const yScale = useMemo(() => {
return d3.scaleLinear()
.domain([0, yMax + 1])
.nice()
.range([height - margin.bottom, margin.top]);
}, [yMax]);
const yTicks = yScale.ticks(5);
const regions = useMemo(() => {
return Array.from(new Set(data.map(d => d.region)));
}, [data]);
const colorScale = useMemo(() => {
return d3.scaleOrdinal<string>()
.domain(regions)
.range(d3.schemeTableau10);
}, [regions]);
const line = d3.line<any>()
.x(d => xScale(d.year)!)
.y(d => yScale(d.value))
.curve(d3.curveMonotoneX);
/**
* AXES TICKS
*/
return (
<section className="w-full max-w-4xl flex">
{/* CONTROLS */}
<div className="rounded-xl bg-white text-gray-700 shadow-sm p-6">
<h2>Taux d'imposition moyen par région</h2>
<label className="text-sm font-medium text-gray-700">
Taxe
</label>
<select
onChange={e => setTaxe(e.target.value)}
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
{taxes.map(t => (
<option key={t.route} value={t.route}>{t.label}</option>
))}
</select>
<label className="text-sm font-medium text-gray-700">
De
</label>
{/* Année de début */}
<select
value={minYear}
onChange={e => setMinYear(Number(e.target.value))}
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{startYears.map(year => (
<option key={year + "start"} value={year}>
{year}
</option>
))}
</select>
<label className="text-sm font-medium text-gray-700">
à
</label>
{/* Année de fin */}
<select
value={maxYear}
onChange={e => setMaxYear(Number(e.target.value))}
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{endYears.map(year => (
<option key={year + "end"} value={year}>
{year}
</option>
))}
</select>
{/* SVG */}
<svg width={width} height={height}>
{/* Grid */}
{yTicks.map(tick => (
<line
key={tick}
x1={margin.left}
x2={width - margin.right}
y1={yScale(tick)}
y2={yScale(tick)}
stroke="#eee"
/>
))}
{/* Axes */}
<line
x1={margin.left}
x2={margin.left}
y1={margin.top}
y2={height - margin.bottom}
stroke="#999"
/>
<line
x1={margin.left}
x2={width - margin.right}
y1={height - margin.bottom}
y2={height - margin.bottom}
stroke="#999"
/>
{/* Y labels */}
{yTicks.map(tick => (
<text
key={tick}
x={margin.left - 10}
y={yScale(tick)}
textAnchor="end"
alignmentBaseline="middle"
fontSize="10"
fill="#666"
>
{tick} %
</text>
))}
{/* X labels */}
{xTicks.map(tick => (
<text
key={tick}
x={xScale(tick)}
y={height - margin.bottom + 20}
textAnchor="middle"
fontSize="10"
fill="#666"
>
{tick}
</text>
))}
{/* Lignes */}
{[...groupedData.entries()].map(([region, values]) => {
const isActive =
activeRegion === null || activeRegion === region;
return (
<path
key={region}
d={line(values)!}
fill="none"
stroke={colorScale(region)}
strokeWidth={isActive ? 3 : 1.5}
opacity={isActive ? 1 : 0.2}
onMouseEnter={() => setActiveRegion(region)}
onMouseLeave={() => setActiveRegion(null)}
className="transition-all duration-200"
/>
);
})}
{/* Points */}
{[...groupedData.entries()].flatMap(([region, values]) =>
values.map(d => {
const isActive =
activeRegion === null || activeRegion === region;
return (
<circle
key={`${region}-${d.year}`}
cx={xScale(d.year)}
cy={yScale(d.value)}
r={isActive ? 4 : 3}
fill={colorScale(region)}
opacity={isActive ? 1 : 0.3}
/>
);
})
)}
</svg>
{/* Légende */}
<div className="mt-6 flex flex-wrap gap-4 justify-center">
{regions.map(region => {
const isActive =
activeRegion === null || activeRegion === region;
return (
<div
key={region}
className="flex items-center gap-2 cursor-pointer transition-opacity"
onMouseEnter={() => setActiveRegion(region)}
onMouseLeave={() => setActiveRegion(null)}
onClick={() =>
setActiveRegion(activeRegion === region ? null : region)
}
style={{ opacity: isActive ? 1 : 0.4 }}
>
<span
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: colorScale(region) }}
/>
<span className="text-sm text-gray-700">
{region}
</span>
</div>
);
})}
</div>
</div>
</section >
);
}
Ce diff est replié.
......@@ -6,9 +6,11 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"d3": "^7.9.0",
"next": "16.1.6",
"react": "19.2.3",
......@@ -16,13 +18,17 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3": "^7.4.3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^28.0.0",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.18"
}
}
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Diagramme from "../components/Diagramme/Diagramme";
describe("page Diagramme", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
member: [
{ label: "Normandie", value: 100000 },
{ label: "Bretagne", value: 200000 },
],
}),
} as any);
});
it("affiche le titre", async () => {
render(<Diagramme />);
expect(await screen.findByText(/Impôt collecté/i)).toBeInTheDocument();
});
it("fait un appel API au montage", async () => {
render(<Diagramme />);
await screen.findByText(/Impôt collecté/i);
expect(fetch).toHaveBeenCalled();
});
it("rend un svg", async () => {
render(<Diagramme />);
await screen.findByText(/Impôt collecté/i);
const svg = document.querySelector("svg");
expect(svg).toBeInTheDocument();
});
});
import { render } from "@testing-library/react";
import Diagramme_svg from "../components/svg/Diagramme_svg";
import { describe, expect, test } from "vitest";
const mockData = [
{ region: "Normandie", year: 2022, value: 100100 },
{ region: "Bretagne", year: 2022, value: 224500 },
];
describe("Diagramme_svg", () => {
test("rend un svg", () => {
const { container } = render(
<Diagramme_svg
data={mockData}
hoveredRegion={null}
setHoveredRegion={() => {}}
/>
);
expect(container.querySelector("svg")).toBeInTheDocument();
});
test("rend des arcs (path)", () => {
const { container } = render(
<Diagramme_svg
data={mockData}
hoveredRegion={null}
setHoveredRegion={() => {}}
/>
);
const paths = container.querySelectorAll("path");
expect(paths.length).toBe(2);
});
});
import { render, screen } from "@testing-library/react";
import { describe, it, beforeEach, expect, vi } from "vitest";
import Nuage from "../components/Nuage/Nuage";
// Mock
vi.mock("@/src/components/svg/Nuage_svg", () => ({
default: () => <svg data-testid="nuage-svg" />
}));
describe("page Nuage", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn()
// 1er fetch → departements
.mockResolvedValueOnce({
ok: true,
json: async () => ({
member: [
{ nom: "Seine-Maritime" },
{ nom: "Calvados" },
],
}),
} as any)
// 2e fetch → données communes
.mockResolvedValueOnce({
ok: true,
json: async () => ({
member: [
{
nomCommune: "Le Havre",
annee: 2022,
tauxNet: 15,
montantReel: 2000000,
},
{
nomCommune: "Rouen",
annee: 2022,
tauxNet: 18,
montantReel: 1500000,
},
],
}),
} as any);
});
it("affiche le titre", async () => {
render(<Nuage />);
expect(await screen.findByText(/Relation taux d'imposition/i))
.toBeInTheDocument();
});
it("fait les appels API au montage", async () => {
render(<Nuage />);
await screen.findByText(/Relation taux d'imposition/i);
expect(fetch).toHaveBeenCalled();
});
it("rend le svg", async () => {
render(<Nuage />);
expect(await screen.findByTestId("nuage-svg"))
.toBeInTheDocument();
});
it("affiche les 3 selects", async () => {
render(<Nuage />);
const selects = await screen.findAllByRole("combobox");
expect(selects.length).toBe(3);
});
});
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import Nuage_svg from "../components/svg/Nuage_svg";
import { CommuneData } from "@/src/type/CommuneData";
describe("Nuage_svg", () => {
const mockSetHovered = vi.fn();
const mockSetTooltip = vi.fn();
const data: CommuneData[] = [
{ commune: "Le Havre", departement: "Seine-Maritime", year: 2022, taxType: "cves", taxRate: 15, volume: 2000000 },
{ commune: "Rouen", departement: "Seine-Maritime", year: 2022, taxType: "cves", taxRate: 18, volume: 1500000 },
];
const props = {
data,
width: 700,
height: 400,
margin: { top: 40, right: 30, bottom: 50, left: 60 },
hovered: null,
setHovered: mockSetHovered,
setTooltip: mockSetTooltip
};
it("rend un svg avec les axes", () => {
render(<Nuage_svg {...props} />);
const svg = document.querySelector("svg");
expect(svg).toBeInTheDocument();
// Vérifie les axes
const lines = svg?.querySelectorAll("line");
expect(lines?.length).toBeGreaterThanOrEqual(2);
});
it("rend le bon nombre de cercles", () => {
render(<Nuage_svg {...props} />);
const circles = document.querySelectorAll("circle");
expect(circles.length).toBe(data.length);
});
it("appelle setHovered et setTooltip au survol d'un point", () => {
render(<Nuage_svg {...props} />);
const circles = document.querySelectorAll("circle");
// simulate hover
fireEvent.mouseEnter(circles[0]);
expect(mockSetHovered).toHaveBeenCalledWith("Le Havre");
expect(mockSetTooltip).toHaveBeenCalled();
fireEvent.mouseLeave(circles[0]);
expect(mockSetHovered).toHaveBeenCalledWith(null);
expect(mockSetTooltip).toHaveBeenCalledWith(null);
});
it("change le rayon et l'opacité si hovered correspond", () => {
const newProps = { ...props, hovered: "Rouen" };
render(<Nuage_svg {...newProps} />);
const circles = document.querySelectorAll("circle");
// 1er cercle -> Le Havre (pas hovered)
expect(circles[0].getAttribute("r")).toBe("5");
expect(circles[0].getAttribute("opacity")).toBe("0.2");
// 2e cercle -> Rouen (hovered)
expect(circles[1].getAttribute("r")).toBe("8");
expect(circles[1].getAttribute("opacity")).toBe("0.9");
});
});
import { render, screen, waitFor } from "@testing-library/react";
import Temporelle from "../components/Temporelle/Temporelle";
import { describe, test, vi, expect } from "vitest";
// Mock du fetch global
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
member: [
{ label: "Normandie", value: 10 },
{ label: "Bretagne", value: 12 },
],
}),
})
) as any;
describe("page Temporelle", () => {
test("affiche le titre", () => {
render(<Temporelle />);
expect(screen.getByText(/Taux d'imposition moyen/i)).toBeInTheDocument();
});
test("affiche les selects", () => {
render(<Temporelle />);
expect(screen.getByText("Taxe")).toBeInTheDocument();
expect(screen.getByText("De")).toBeInTheDocument();
expect(screen.getByText("à")).toBeInTheDocument();
});
test("fetch les données et affiche le graphique", async () => {
render(<Temporelle />);
await waitFor(() => {
expect(fetch).toHaveBeenCalled();
});
expect(document.querySelector("svg")).toBeInTheDocument();
});
});
import { render } from "@testing-library/react";
import Temporelle_svg from "../components/svg/Temporelle_svg";
import { describe, expect, test } from "vitest";
const mockData = [
{ region: "Normandie", year: 2018, value: 100004 },
{ region: "Normandie", year: 2019, value: 1202210 },
{ region: "Bretagne", year: 2018, value: 422012 },
{ region: "Bretagne", year: 2019, value: 1050259 },
];
describe("Temporelle_svg", () => {
test("render un svg", () => {
const { container } = render(
<Temporelle_svg
data={mockData}
minYear={2018}
maxYear={2019}
activeRegion={null}
setActiveRegion={() => {}}
/>
);
expect(container.querySelector("svg")).toBeInTheDocument();
});
test("render des lignes", () => {
const { container } = render(
<Temporelle_svg
data={mockData}
minYear={2018}
maxYear={2019}
activeRegion={null}
setActiveRegion={() => {}}
/>
);
const paths = container.querySelectorAll("path");
expect(paths.length).toBeGreaterThan(0);
});
});
import Diagramme from "@/components/Diagramme/Diagramme";
import Diagramme from "@/src/components/Diagramme/Diagramme";
export default function CorrelationPage() {
return (
......
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Commons/Header";
import Footer from "@/components/Commons/Footer";
import Header from "@/src/components/Commons/Header";
import Footer from "@/src/components/Commons/Footer";
import QueryProvider from "@/src/providers/QueryProvider";
const geistSans = Geist({
......@@ -30,9 +32,11 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Header/>
{children}
<Footer/>
<QueryProvider>
<Header />
{children}
<Footer />
</QueryProvider>
</body>
</html>
);
......
import Nuage from "@/components/Nuage/Nuage";
import Nuage from "@/src/components/Nuage/Nuage";
export default function CloudPage() {
return (
......
import Header from "@/components/Commons/Header";
import Footer from "@/components/Commons/Footer";
import Temporelle from "@/components/Temporelle/Temporelle";
import Temporelle from "@/src/components/Temporelle/Temporelle";
export default function Page() {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center px-6 py-10">
<Temporelle />
</main>
<Footer />
</div>
);
}
......
import Temporelle from "@/components/Temporelle/Temporelle";
import Temporelle from "@/src/components/Temporelle/Temporelle";
export default function Page() {
return (
......
Ce diff est replié.