Récupération et affichage de données Excel/CSV dans une application Angular : Tableaux et Dashboard

Pour tirer pleinement parti de ce tutoriel, une connaissance de base de technologies suivantes est recommandée :
Prérequis
Spring Boot
Comprendre la structure d’un projet Spring Boot
Comprendre l’architecture Controller > Service > Repository
Savoir utiliser les annotations (
@RestController
,@Service
,@Repository
)Connaître le rôle du fichier
pom.xml
(gestion des dépendances Maven )Programmation orientée objet ( classes, interface, méthodes)
Manipulation des collections (
List
,Map
)
Base de données
Dans ce projet, nous utiliserons MySQL comme base de données. La configuration se fera simplement via le fichier
application.properties
de Spring Boot, en y définissant :La source de données (DataSource)
Les paramètres de connexion JPA/Hibernate
# Configuration MySQL
spring.datasource.url=jdbc:mysql://localhost:3306/nom_de_votre_bdd?useSSL=false
spring.datasource.username=votre_utilisateur
spring.datasource.password=votre_mot_de_passe
# Paramètres JPA/Hibernate
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
Angular
Utilisation de
HttpClient
pour les requêtes APIRxJS (Observables,
subscribe
)
Outils & Environnement
Node.js et Angular CLI istallés
IDE recommandé : VS Code ou Intellij
Vous pouvez retrouver d'autres tutoriels utiles sur ma chaîne YouTube algostyle. J'ai prévu de créer prochainement une vidéo détaillée sur ce projet - abonnez-vous pour être averti dès sa sortie !
N'hésitez pas à me faire part de vos questions ou suggestions en commentaire, cela m'aidera à préparer un contenu vidéo qui réponde au mieux à vos besoins.
Pour approfondir le sujet, vous pouvez également consulter mes vidéos sur la création de tableaux de bord avec Power BI disponibles sur ma chaîne.
Backend
Voyons d'abord les dépendances nécessaires à configurer dans pom.xml
<dependencies>
<!-- Fournir tout le nécessaire pour utiliser JPA (Java Persistence API) avec Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Pour développer des applications web et API REST -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--
* Driver JDBC officiel pour MySQL
* Marqué comme runtime car seulement nécessaire pendant l'exécution
-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lire/écrire des fichiers Excel (format .xls) -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.2.3</version>
</dependency>
<!-- Extension pour les formats Excel modernes (.xlsx) -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
</dependency>
<!-- Pour parser et générer des fichiers CSV -->
<dependency>
<groupId>com.opencsv</groupId>
<artifactId>opencsv</artifactId>
<version>5.7.1</version>
</dependency>
</dependencies>
On créé les packages suivants:
Dans le package entities
, on créé l’entité FichiersExcel
:
@Entity
@Table(name = "fichiers_excel")
public class FichiersExcel {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String nom;
@Column(unique = true)
private String chemin;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getNom() {
return nom;
}
public void setNom(String nom) {
this.nom = nom;
}
public String getChemin() {
return chemin;
}
public void setChemin(String chemin) {
this.chemin = chemin;
}
}
Dans le package repositories
, on crée l’interface FilePathRepository
public interface FilePathRepository extends JpaRepository<FichiersExcel,Long> {
}
Pour la couche métier, on créé le service suivant dans le package services
Ce service lit un fichier Excel et retourne son contenu sous forme de liste de maps (clé/valeur)
@Service
public class ExcelService {
@Autowired
private FilePathRepository filePathRepository;
// Méthode qui prend un chemin de fichier Excel en entrée et retourne les données lues
public List<Map<String, String>> readExcelFile(String filePath) throws IOException {
// Création d'un objet File à partir du chemin donné
File file = new File( filePath);
// Utilisation de la classe WorkbookFactory (Apache POI) pour ouvrir le fichier
Workbook workbook = WorkbookFactory.create(file);
// On récupère la première feuille (index 0)
Sheet sheet = workbook.getSheetAt(0);
// Liste finale contenant les lignes du fichier, sous forme de Map (entête -> valeur)
/**
* -- Exemple de liste à partir de fichier --
* [
* { "Nom":"Ali" , "âge":"22" , "Ville":"rabat" },
* { "Nom":"Leila" , "âge":"25" , "Ville":"Casablanca"}
* ]
*/
List<Map<String, String>> data = new ArrayList<>();
// Lecture de la première ligne ( l'en-tête )
Row headerRow = sheet.getRow(0);
List<String> headers = new ArrayList<>();
for (Cell cell : headerRow) { // Récupérer chaque cellule de l'en-tête pour extraire les noms de colonnes
headers.add(cell.getStringCellValue()); // ex: "Nom", "Age", "Ville" ....
}
// --- Lire les données ---
// On commence à la ligne 1 (car 0=en-tête)
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
Map<String, String> rowData = new HashMap<>(); // Une ligne = une Map (colonne -> valeur)
// On parcourt chaque colonne de l'en-tête
for (int j = 0; j < headers.size(); j++) {
// On récupère la cellule correspondante (si elle est vide, on crée une cellule vide)
Cell cell = row.getCell(j, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
// On détecte le type de la cellule et on convertit sa valeur en String
switch (cell.getCellType()) {
case STRING:
rowData.put(headers.get(j), cell.getStringCellValue());
break;
case NUMERIC:
rowData.put(headers.get(j), String.valueOf(cell.getNumericCellValue()));
break;
case BOOLEAN:
rowData.put(headers.get(j), String.valueOf(cell.getBooleanCellValue()));
break;
default:
rowData.put(headers.get(j), "");
}
}
// On ajoute la ligne traitée à la liste finale
data.add(rowData);
}
// On ferme le classeur Excel pour libérer les ressources
workbook.close();
// On retourne les données lues
return data;
}
public List<FichiersExcel> getAllFiles(){
return this.filePathRepository.findAll();
}
}
Concernant la couche API, on créé le contrôleur ExcelController
dans le package controllers
Ce contrôleur contient deux endpoints.
Le premier endpoint /dashboard/{id}
cherche le chemin du fichier Excel à partir de son identifiant
lit le fichier via
ExcelService
retourne les données lues sous forme de
List< Map<String, String>>
Le deuxième endpoint permet de retourner les fichiers stockés dans la base de données
Voici le code bien commenté
@RestController // Gérer les requêtes liées à l'importation de fichiers excel
@RequestMapping("/api/excel") // Tous les URLs de ce contrôleur commencent par /api/excel
@CrossOrigin(origins = "http://localhost:4200") // Autoriser les requêtes CORS depuis Angular (port 4200)
public class ExcelController {
// Injection automatique du service qui contient la logique de lecture des fichiers Excel
@Autowired
private ExcelService excelService;
// Injection du repository pour accéder aux chemins des fichiers stockés en BD
@Autowired
private FilePathRepository filePathRepository; // Votre repository pour les chemins de fichiers
// Endpoints GET pour récupérer les données Excel sous forme de liste de Map (clé/valeur)
@GetMapping("/dashboard/{id}")
public ResponseEntity<List<Map<String, String>>> getExcelData(@PathVariable Long id) {
try {
// Récupérer le chemin du fichier Excel en fonction de l'identifiant passé dans l'URL
String filePath = filePathRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Fichier non trouvé"))
.getChemin();
// Utiliser le service pour lire les données du fichier Excel
List<Map<String, String>> data = excelService.readExcelFile(filePath);
// Afficher le contenu du fichier
for(Map<String, String> map:data){
for(Map.Entry<String, String> entry : map.entrySet()){
System.out.println("key: "+entry.getKey() + " - value: "+entry.getValue());
}
System.out.println("------------------------");
}
// Retourner les données dans la réponse HTTP avec un code 200 OK
return ResponseEntity.ok(data);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping
public ResponseEntity<List<FichiersExcel>> getAllIFiles(){
return ResponseEntity.ok(this.excelService.getAllFiles());
}
}
Frontend
On crée un service API (HttpClient
) pour la communication backend
@Injectable({
providedIn: 'root'
})
export class DashboardService {
private apiUrl = 'http://localhost:8080/api/excel';
constructor(private http: HttpClient) { }
getExcelData(fileId: number): Observable<any[]> {
return this.http.get<any[]>(`${this.apiUrl}/dashboard/${fileId}`);
}
getAllFiles() : Observable<any[]>{
return this.http.get<any[]>(`${this.apiUrl}`);
}
}
Premier composant Angular : ExcelDashboardComponent
J’ai créé le premier composant ExcelDashboardComponent
.
Le rôle principal de ce composant Angular est de charger et d’afficher les données d’un fichier Excel à partir d’un backend.
On veut afficher dynamiquement les données d’un fichier Excel (identifié par un fileId
) dans un tableau, avec les colonnes extraites automatiquement, tout en gérant l’état de chargement et la récupération des fichiers disponibles.
@Component({
selector: 'app-excel-dashboard',
imports: [NgIf, NgFor,FormsModule],
templateUrl: './excel-dashboard.component.html',
styleUrl: './excel-dashboard.component.css'
})
export class ExcelDashboardComponent implements OnInit {
// Tableau qui contiendra toutes les lignes de donées du fichier Excel
data: any[] = [];
// Tableau pour stocker les noms des colonnes (ex: ["Nom", "Age", "Ville"])
columns: string[] = [];
// Indicateur d'état de chargement, utilisé pour afficher un spinner au bloquer les actions pendant le chargement
loading = false;
// Variable pour stocker l'ID du fichier à lire
myId:number=-1;
files : any[] = [];
// Injection du service DashboardService pour faire l'appel HTTP au backend
constructor(
private dashboardService: DashboardService,
) { }
// Méthode exécuté automatiquement au moment où le composant est initialisé
ngOnInit(): void {
this.lireFichier(this.myId);
this.getFiles();
}
// Méthod epour lire un fichier Excel à partir de son identifiant (fileId)
lireFichier(fileId : number){
this.loading = true; // Active l'indicateur de chargement
// Appel du service qui va chercher les données Excel depuis l'API
this.dashboardService.getExcelData(fileId).subscribe({
// En casde succès
next: (data) => {
this.data = data; // On stocke les données Excel dans la variable 'data'
// Si le tableau n'est pas vide, on extrait les noms des colonnes
if (data.length > 0) {
this.columns = Object.keys(data[0]); // On récupère les clés du header qui représente les entêtes de colonnes
}
this.loading = false; // désactive le chargement
},
error: (err) => {
console.error('Error loading data', err);
this.loading = false;
}
});
}
getFiles(){
this.dashboardService.getAllFiles().subscribe(res=>{
this.files=res;
})
}
}
Voici le contenu HTML
<div class="dropdown mb-3">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Sélectionner un fichier
</button>
<ul class="dropdown-menu">
<li *ngFor="let file of files"><a class="dropdown-item" (click)="lireFichier(file.id)">{{file.nom}}</a></li>
</ul>
</div>
<div class="dashboard">
<!-- Message de chargement : ce qu'on peut afficher en attendant l'affichage des données -->
<div *ngIf="loading" class="loading">
<div class="spinner"></div>
<p>Chargement des données...</p>
</div>
<!-- Si les données existe et le chargement termine, on affiche le tableau des données -->
<div *ngIf="!loading && data.length > 0">
<table class="data-table">
<thead>
<tr>
<!-- Boucle pour générer les en-têtes de colonnes -->
<th *ngFor="let column of columns">{{ column }}</th>
</tr>
</thead>
<tbody>
<!-- Boucle pour générer les lignes de données -->
<tr *ngFor="let row of data">
<td *ngFor="let column of columns">{{ row[column] }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Si aucune donnée n'est disponible, on affiche le message suivant -->
<div *ngIf="!loading && data.length === 0" class="no-data">
<p>Aucune donnée disponible.</p>
</div>
</div>
Et voici le contenu CSS
.dashboard {
font-family: Arial, sans-serif;
max-width: 100%;
padding: 20px;
}
/* Style du tableau */
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.data-table th, .data-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.data-table th {
background-color: #f2f2f2;
}
.data-table tr:nth-child(even) {
background-color: #f9f9f9;
}
/* Style du message de chargement */
.loading {
text-align: center;
padding: 20px;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #3498db;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Style quand il n'y a pas de données */
.no-data {
text-align: center;
padding: 20px;
color: #666;
}
Deuxième composant Angular : ExcelDashborad2Component
Le composant suivant permet de charger un fichier Excel depuis un backend, d’analyser automatiquement ses colonnes pour en déterminer le type (texte, nombre ou mixte), d’évaluer leur pertinence pour la visualisation, puis de générer dynamiquement un graphique interactif (via Chart.js que vous devez installer, voir mon article précédant qui explique ça : Chart.js avec Angular) basé sur la colonne la plus adaptée (ou sélectionnée manuellement), tout en proposant des recommandations, en gérant les couleurs, le type de graphique, et en nettoyant les ressources à la destruction du composant.
Voici la classe TypeScript qui contient la logique métier du composant.
Elle contrôle les données, gère les événements et communique avec les services.
Voici les modules et services nécessaires que j’ai importé depuis Angular et Chart.js
import { Component, ElementRef, ViewChild, AfterViewInit, OnDestroy, ChangeDetectorRef, OnInit } from '@angular/core';
import { NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DashboardService } from '../../services/dashboard.service';
import { Chart, ChartType, registerables } from 'chart.js';
J’ai créé une interface agissant comme modèle TypeScript décrivant les caractéristiques d’une colonne Excel pour déterminer si on peut faire un graphique. En fait, cette interface permet de standardiser l’objet retourné par la méthode analyzeColumn().
interface ColumnAnalysis {
name: string; // Nom de la colonne
type: 'text' | 'number' | 'mixed'; // Type de données détecté
uniqueValues: number; // Nombre de valeurs uniques
totalValues: number; // Nombre total de valeurs non vides
sampleValues: string[]; // Exemples de valeurs (5 premières)
isGoodForVisualization: boolean; // Si la colonne est adaptée à un graphique
}
Un objet qui implémente cette interface répond aux questions suivantes:
→ Quel est le type de données dominant dans cette colonne ?
→ Combien de valeurs distinctes contient-elle ?
→ Peut-on l’utiliser comme axe X ou comme catégorie dans un chart ?
Notre classe implémente les interfaces suivantes :
export class ExcelDashborad2Component implements AfterViewInit, OnDestroy, OnInit
Voici les variables à utiliser :
// Référence au canvas HTML pour Chart.js
@ViewChild('chartCanvas', { static: true }) chartCanvas!: ElementRef<HTMLCanvasElement>;
data: any[] = []; // Données brutes du fichier Excel
rawColumns: string[] = []; // Noms des colonnes brutes
analyzedColumns: ColumnAnalysis[] = []; // Colonnes analysées
loading = false; // Etat de chargement
chart: Chart | undefined; // Instance du graphique Chart.js
chartType: ChartType = 'bar'; // type du graphique ( 'bar' par défaut )
files : any[]=[]; // Liste des fichiers disponibles
// Colonnes sélectionnées pour la visualisation
selectedColumn = '';
visualizableColumns: ColumnAnalysis[] = [];
On utilise le constructeur pour injecter les services suivants :
constructor(
private dashboardService: DashboardService, // Service pour les appels API
private cdr: ChangeDetectorRef // Pour forcer la détection de changement
) {
Chart.register(...registerables); // Enregistrement des plugins Chart.js
}
On implémente également le ngOnInit() pour le chargement initial des fichiers disponibles au démarrage du composant
ngOnInit(){
console.log("-- ngOnInit --")
this.getFiles();
}
ngAfterViewInit() {
// Affichage dans la console du navigateur une fois que la vue est prête
// pour vérifier que le composant est bien rendu
console.log('-- Component initialisé --');
}
On créé la méthode getFiles() qui permet de récupérer les fichiers disponibles depuis le backend via le service
getFiles(){
this.dashboardService.getAllFiles().subscribe(res=>{
this.files=res;
})
}
La méthode lireFichier( fileId : number)
permet de charger les données d’un fichier Excel sélectionné
lireFichier(fileId: number) {
this.loading = true;
this.resetData(); // Réinitialisation des données précédentes
this.dashboardService.getExcelData(fileId).subscribe({
next: (data) => {
console.log('Données reçues:', data);
this.data = data;
if (data.length > 0) {
this.rawColumns = Object.keys(data[0]); // Extraction des noms de colonnes
this.analyzeColumns(); // Analyse des colonnes
this.selectBestColumnForVisualization(); // Sélectionner les colonnes qui peuvent être visualisées
this.cdr.detectChanges(); // Mise à jour de la vue
// Double requestAnimationFrame pour s'assurer que la vue est mise à jour
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (this.selectedColumn) {
this.generateChartData(); // Génération du graphique
}
});
});
}
this.loading = false;
},
error: (err) => {
console.error('Erreur lors du chargement:', err);
this.loading = false;
}
});
}
La méthode suivante permet de réinitialiser toutes les données et le graphique
private resetData() {
this.data = [];
this.rawColumns = [];
this.analyzedColumns = [];
this.visualizableColumns = [];
this.selectedColumn = '';
if (this.chart) {
this.chart.destroy(); // Nettoyage du graphiqye existant
this.chart = undefined;
}
}
La méthode analyzeColumns()
permet d’analyser toutes les colonnes pour déterminer leur type et utilité
private analyzeColumns() {
this.analyzedColumns = this.rawColumns.map(columnName => {
const analysis = this.analyzeColumn(columnName);
console.log(`Analyse de "${columnName}":`, analysis);
return analysis;
});
// Filtrer les colonnes bonnes pour la visualisation
this.visualizableColumns = this.analyzedColumns.filter(col => col.isGoodForVisualization);
console.log('Colonnes visualisables:', this.visualizableColumns.map(c => c.name));
}
On aura besoin également d’une méthode qui analyse une colonne spécifique
private analyzeColumn(columnName: string): ColumnAnalysis {
// Extraction et nettoyage des valeurs
const values = this.data.map(row => row[columnName])
.filter(val => val !== null && val !== undefined && val !== '');
const uniqueValues = [...new Set(values)]; // Les valeurs uniques
const totalValues = values.length;
const uniqueCount = uniqueValues.length;
// Échantillon des premières valeurs uniques (max 5)
// Utilisé dans l'UI pour donner un aperçu à l'utilisateur
const sampleValues = uniqueValues.slice(0, 5).map(v => String(v));
// Déterminer le type de données
// 80% de numériques = colonne numérique
const numericValues = values.filter(val => !isNaN(Number(val)) && val !== ''); // retirer les chaînes vides, convertir les autres en nombres, et ne garder que celles qui donnent des nombres valides
const isNumeric = numericValues.length > totalValues * 0.8; // 80% sont numériques
let type: 'text' | 'number' | 'mixed';
if (isNumeric) { // cas 1 : >80% de nombres
type = 'number';
} else if (numericValues.length === 0) { // Cas 2 : 0 nombre détecté
type = 'text';
} else { // Cas 3 : Entre 1% et 79% de nombres
type = 'mixed';
}
// Critères pour déterminer si c'est bon pour la visualisation
const duplicateRatio = (totalValues - uniqueCount) / totalValues;
const isGoodForVisualization =
uniqueCount > 1 && // Au moins 2 valeurs différentes
uniqueCount < totalValues * 0.8 && // Pas trop unique (max 80% unique)
duplicateRatio > 0.1 && // Au moins 10% de doublons
uniqueCount <= 15; // Maximum 15 catégories différentes pour la lisibilité
return {
name: columnName,
type,
uniqueValues: uniqueCount,
totalValues,
sampleValues,
isGoodForVisualization
};
}
On utilise la méthode suivante pour sélectionner automatiquement la meilleure colonne pour le graphique
private selectBestColumnForVisualization() {
if (this.visualizableColumns.length === 0) {
console.log('Aucune colonne appropriée pour la visualisation');
return;
}
// Trier par ordre de préférence :
// 1. Moins de valeurs uniques (plus de regroupement)
// 2. Plus de valeurs totales
// 3. Type texte de préférence
const sorted = this.visualizableColumns.sort((a, b) => {
// Priorité 1: moins de valeurs uniques
if (a.uniqueValues !== b.uniqueValues) {
return a.uniqueValues - b.uniqueValues;
}
// Priorité 2: plus de données
if (a.totalValues !== b.totalValues) {
return b.totalValues - a.totalValues;
}
// Priorité 3: préférer text > mixed > number pour les catégories
const typeOrder = { 'text': 0, 'mixed': 1, 'number': 2 };
return typeOrder[a.type] - typeOrder[b.type];
});
this.selectedColumn = sorted[0].name;
console.log('Colonne sélectionnée automatiquement:', this.selectedColumn);
}
Pour générer le graphique avec Chart.js avec la colonne sélectionnée, et le tableau de couleurs pour les données, ainsi que les couleurs des bordures, on utilise l’ensemble des méthode suivantes:
// Générer le graphique avec Chart.js avec la colonne sélectionnée
generateChartData() {
if (!this.chartCanvas?.nativeElement || !this.selectedColumn || this.data.length === 0) {
console.log('Conditions non remplies pour générer le graphique');
return;
}
// Comptage des occurrences par valeur
const counts: { [key: string]: number } = {};
let processedValues = 0;
this.data.forEach(row => {
const value = row[this.selectedColumn];
if (value !== undefined && value !== null && value !== '') {
const cleanValue = String(value).trim();
if (cleanValue) {
counts[cleanValue] = (counts[cleanValue] || 0) + 1;
processedValues++;
}
}
});
console.log(`Comptages pour "${this.selectedColumn}":`, counts);
console.log(`Valeurs traitées: ${processedValues} sur ${this.data.length}`);
// Trier par nombre d'occurrences (descendant)
const sortedEntries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
const labels = sortedEntries.map(entry => entry[0]);
const data = sortedEntries.map(entry => entry[1]);
// Nettoyage du graphique précédent
if (this.chart) {
this.chart.destroy();
}
// Adapter le type de graphique selon le nombre de catégories
let recommendedType = this.chartType;
if (labels.length > 10 && (this.chartType === 'pie' || this.chartType === 'doughnut')) {
recommendedType = 'bar'; // Trop de catégories pour un graphique en secteurs / Les secteurs deviennent illisibles
}
// Création du graphique
this.chart = new Chart(this.chartCanvas.nativeElement, {
type: recommendedType,
data: {
labels: labels,
datasets: [{
label: `Répartition par ${this.selectedColumn.trim()}`,
data: data,
backgroundColor: this.generateColors(labels.length),
borderColor: this.generateBorderColors(labels.length),
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: labels.length > 8 ? 'right' : 'top',
display: labels.length <= 15 // Cacher la légende si trop de catégories
},
title: {
display: true,
text: `Répartition par ${this.selectedColumn.trim()} (${processedValues} éléments)`
}
},
// Configuration des axes pour les graphiques non-secteurs
scales: recommendedType === 'pie' || recommendedType === 'doughnut' ? {} : {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
},
x: {
ticks: {
maxRotation: labels.some(l => l.length > 10) ? 45 : 0,
minRotation: 0
}
}
}
}
});
console.log(`Graphique créé (${recommendedType}) pour: ${this.selectedColumn}`);
}
// Générer un tableau de couleurs pour les données
generateColors(count: number): string[] {
// Couleurs de base Chart.js
const baseColors = [
'rgba(67, 56, 202, 0.8)', 'rgba(16, 185, 129, 0.8)', 'rgba(245, 101, 101, 0.8)',
'rgba(251, 191, 36, 0.8)', 'rgba(139, 69, 19, 0.8)', 'rgba(75, 85, 99, 0.8)',
'rgba(219, 39, 119, 0.8)', 'rgba(34, 197, 94, 0.8)', 'rgba(249, 115, 22, 0.8)',
'rgba(168, 85, 247, 0.8)', 'rgba(20, 184, 166, 0.8)', 'rgba(220, 38, 127, 0.8)'
];
const colors = [];
for (let i = 0; i < count; i++) {
if (i < baseColors.length) {
colors.push(baseColors[i]);
} else {
// Générer des couleurs aléatoires pour les catégories supplémentaires
const hue = (i * 137.5) % 360; // Distribution dorée - Angle doré pour la répartition
colors.push(`hsla(${hue}, 70%, 60%, 0.8)`);
}
}
return colors;
}
// Générer des couleurs de bordure
// En modifiant l'opacité alpha (la transparence) d'un ensemble de couleurs.
generateBorderColors(count: number): string[] {
return this.generateColors(count).map(color => color.replace('0.8)', '1)'));
}
Finalement, les méthodes suivantes sont utilitaires pour l’interaction avec le graphique:
// - Handler de changement de colonne sélectionnée
// - Cette méthode est appelée quand l'utilisateur change la colonne sélectionnée
onColumnChange() {
if (this.selectedColumn && this.data.length > 0) {
this.generateChartData();
}
}
// Changer le type de graphique
changeChartType(type: ChartType) {
this.chartType = type;
if (this.selectedColumn && this.data.length > 0) {
this.generateChartData();
}
}
// Obtenir des recommandations pour l'utilisateur
// Donne un conseil lisible sur l'utilité d'une colonne
getColumnRecommendation(column: ColumnAnalysis): string {
if (!column.isGoodForVisualization) {
if (column.uniqueValues === column.totalValues) {
return 'Trop unique (chaque valeur est différente)';
}
if (column.uniqueValues > 15) {
return 'Trop de catégories différentes';
}
if (column.uniqueValues === 1) {
return 'Une seule valeur unique';
}
return 'Non recommandé pour la visualisation';
}
const duplicateRatio = (column.totalValues - column.uniqueValues) / column.totalValues;
if (duplicateRatio > 0.7) {
return '✔️ Excellent pour la visualisation';
} else if (duplicateRatio > 0.4) {
return '✔️ Bon pour la visualisation';
} else {
return '⚠️ Acceptable pour la visualisation';
}
}
La dernière méthode est celle qui permet de nettoyer le graphique à la fermeture du composant pour éviter des fuites mémoire
ngOnDestroy() {
if (this.chart) {
this.chart.destroy();
}
}
Je partage avec vous également le code HTML utilisé:
<div class="container">
<!-- Section contenant un dropdow permettant de sélectionner le fichier à visualiser -->
<div class="test-section">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Choisir un fichier
</button>
<ul class="dropdown-menu">
<li *ngFor="let file of files"><a class="dropdown-item" (click)="lireFichier(file.id)">{{file.nom}}</a></li>
</ul>
</div>
</div>
<!-- État de chargement -->
<div *ngIf="loading" class="loading">
<div class="spinner"></div>
<p>Analyse du fichier en cours...</p>
</div>
<!-- Analyse des colonnes -->
<div class="column-analysis" *ngIf="!loading && analyzedColumns.length > 0">
<h3>Analyse des colonnes</h3>
<div class="analysis-grid">
<div *ngFor="let col of analyzedColumns"
class="column-card"
[class.recommended]="col.isGoodForVisualization"
[class.selected]="col.name === selectedColumn">
<h4>{{ col.name.trim() }}</h4>
<div class="column-stats">
<span class="stat">
<strong>Type:</strong>
<span [class]="'type-' + col.type">{{ col.type }}</span>
</span>
<span class="stat">
<strong>Valeurs uniques:</strong> {{ col.uniqueValues }}/{{ col.totalValues }}
</span>
<span class="recommendation">
{{ getColumnRecommendation(col) }}
</span>
</div>
<div class="sample-values" *ngIf="col.sampleValues.length > 0">
<strong>Exemples:</strong> {{ col.sampleValues.join(', ') }}
</div>
<button *ngIf="col.isGoodForVisualization"
(click)="selectedColumn = col.name; onColumnChange()"
class="select-column-btn btn btn-outline-success"
[disabled]="col.name === selectedColumn">
{{ col.name === selectedColumn ? '✅ Sélectionné' : 'Sélectionner' }}
</button>
</div>
</div>
</div>
<!-- Sélecteur de colonne (alternative) -->
<div class="column-selector" *ngIf="!loading && visualizableColumns.length > 0">
<label for="columnSelect">
<strong>Colonne à visualiser:</strong>
</label>
<select id="columnSelect" [(ngModel)]="selectedColumn" (change)="onColumnChange()">
<option value="">-- Choisir une colonne --</option>
<option *ngFor="let column of visualizableColumns" [value]="column.name">
{{ column.name.trim() }} ({{ column.uniqueValues }} catégories)
</option>
</select>
</div>
<!-- Contrôles de graphique (pour le choix du type du graphique) -->
<div class="chart-controls" *ngIf="!loading && selectedColumn">
<h3>Type de graphique</h3>
<div class="chart-type-buttons">
<button (click)="changeChartType('bar')"
[class.active]="chartType === 'bar'"
class="chart-btn bar-btn">
Barres
</button>
<button (click)="changeChartType('pie')"
[class.active]="chartType === 'pie'"
class="chart-btn pie-btn">
Secteurs
</button>
<button (click)="changeChartType('doughnut')"
[class.active]="chartType === 'doughnut'"
class="chart-btn doughnut-btn">
Donut
</button>
<button (click)="changeChartType('line')"
[class.active]="chartType === 'line'"
class="chart-btn line-btn">
Lignes
</button>
</div>
</div>
<!-- Zone de graphique -->
<div class="chart-container"
[style.display]="!loading && selectedColumn ? 'block' : 'none'">
<canvas #chartCanvas></canvas>
</div>
<!-- Messages d'aide -->
<div *ngIf="!loading && data.length > 0 && visualizableColumns.length === 0"
class="no-visualization">
<h3>⚠️ Aucune colonne appropriée pour la visualisation</h3>
<p>Ce fichier contient principalement des données uniques (comme des identifiants ou des noms)
qui ne sont pas adaptées aux graphiques de répartition.</p>
<p><strong>Suggestions:</strong></p>
<ul>
<li>Vérifiez s'il y a des colonnes de catégories (région, type, statut...)</li>
<li>Les colonnes avec des valeurs répétées fonctionnent mieux</li>
<li>Évitez les colonnes avec des identifiants uniques</li>
</ul>
</div>
<div *ngIf="!loading && data.length === 0" class="no-data">
<h3>Aucune donnée chargée</h3>
<p>Cliquez sur un des boutons ci-dessus pour charger un fichier.</p>
</div>
<!-- Informations de debug -->
<div class="debug-info" *ngIf="!loading && data.length > 0">
<h4>Informations sur le fichier:</h4>
<div class="debug-stats">
<span><strong>Lignes:</strong> {{ data.length }}</span><br>
<span><strong>Colonnes totales:</strong> {{ rawColumns.length }}</span><br>
<span><strong>Colonnes visualisables:</strong> {{ visualizableColumns.length }}</span><br>
<span *ngIf="selectedColumn"><strong>Colonne active:</strong> {{ selectedColumn.trim() }}</span><br>
</div>
</div>
</div>
également le contenu du fichier de style:
.chart-controls {
margin-bottom: 20px;
}
.chart-controls h3{
margin-bottom: 28px;
}
.chart-controls button {
margin-right: 3rem;
padding: 15px 26px;
border: 1px solid #ddd;
background: #f5f5f5;
cursor: pointer;
}
.chart-container {
width: 100%;
margin-bottom: 30px;
}
/* section du choix du fichier*/
.test-section {
margin: 30px;
text-align: center;
li{
cursor: pointer;
}
}
.load-btn {
margin: 0 10px 10px 0;
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.load-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.loading {
text-align: center;
padding: 60px 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 12px;
margin: 20px 0;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.column-analysis {
margin: 30px 0;
padding: 10px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.column-analysis h3 {
color: #333;
border-bottom: 3px solid #d40596;
padding-bottom: 10px;
}
.analysis-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.column-card {
padding: 20px;
border: 2px solid #e0e0e0;
border-radius: 10px;
background: #fafafa;
transition: all 0.3s ease;
}
.column-card.recommended {
border-color: #4caf50;
background: linear-gradient(135deg, #f4f7f0 0%, #f3f8f3 100%);
}
.column-card.selected {
border-color: #667eea;
background: linear-gradient(135deg, #e3f2fd 0%, #e1f5fe 100%);
transform: scale(1.02);
}
.column-card h4 {
margin: 0 0 15px 0;
color: #333;
font-size: 20px;
text-align: center;
}
.column-stats {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 15px;
}
.stat {
font-size: 14px;
color: #666;
}
.type-text { color: #2196f3; font-weight: bold; }
.type-number { color: #ff9800; font-weight: bold; }
.type-mixed { color: #9c27b0; font-weight: bold; }
.recommendation {
font-size: 13px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
background: #f5f5f5;
}
.sample-values {
font-size: 12px;
color: #888;
margin-bottom: 15px;
padding: 8px;
background: #f9f9f9;
border-radius: 4px;
}
.select-column-btn:hover:not(:disabled) {
border: 2px solid #5a67d8;
transform: translateY(-1px);
}
.select-column-btn:disabled {
border: 2px solid #4caf50;
cursor: not-allowed;
}
.column-selector {
padding: 20px;
background: linear-gradient(135deg, #ea66d0 0%, #c39ac0 100%);
color: white;
border-radius: 10px;
}
.column-selector select {
width: 100%;
padding: 10px;
margin-top: 10px;
border: none;
border-radius: 6px;
font-size: 16px;
}
.chart-controls {
margin: 30px 0;
text-align: center;
padding: 25px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.chart-type-buttons {
display: flex;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.chart-btn {
padding: 12px 20px;
border: 2px solid #ddd;
background: white;
cursor: pointer;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
transition: all 0.3s ease;
}
.chart-btn:hover {
border-color: #ff8800 !important;
border-width: 2px !important;
transform: translateY(-2px);
}
.chart-btn.active {
background: #667eea;
color: #01b15f;
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.chart-container {
width: 100%;
height: 500px;
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
margin: 30px 0;
}
.no-visualization, .no-data {
text-align: center;
padding: 40px;
background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
border-radius: 12px;
margin: 20px 0;
color: #333;
}
.no-visualization h3, .no-data h3 {
margin-top: 0;
}
.no-visualization ul {
text-align: left;
display: inline-block;
}
.debug-info {
margin: 30px 0;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
border-left: 4px solid #ff007b;
}
.debug-info h4 {
margin: 20px 0;
color: #333;
}
.debug-stats {
display: flex;
flex-wrap: wrap;
gap: 50px;
}
.debug-stats span {
font-size: 14px;
color: #666;
}
@media (max-width: 768px) {
.analysis-grid {
grid-template-columns: 1fr;
}
.chart-type-buttons {
flex-direction: column;
align-items: center;
}
.debug-stats {
flex-direction: column;
gap: 10px;
}
}
Il est temps de conclure cet article! Je suis en train d’enregistrer une vidéo de démonstration du résultat final, accompagnée d’une explication générale du code développé.
N’hésitez pas à :
Posez vos questions en commentaire.
Partager vos expériences similaires
Visiter ma chaîne YouTube ==> algostyle.
Me contacter sur LinkedIn pour échanger : Asmae Aouassar
Merci pour votre lecture et à bientôt pour de nouveaux projets!
Subscribe to my newsletter
Read articles from Aouassar Asmae directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
