Visualisation Heatmap Unifiée avec Apache ECharts
Architecture Unifiée de la Visualisation
Vue d’ensemble
La Heatmap de recommandations unifiée utilise Apache ECharts 5.4.3 via CDN pour offrir une visualisation interactive et performante des scores de datasets. Cette même heatmap est maintenant utilisée dans deux contextes :
-
Création de projet : Pour prévisualiser les recommandations en temps réel
-
Détail de projet : Pour afficher les recommandations finales avec scores pré-calculés
Stack technique : - Librairie : Apache ECharts 5.4.3 (CDN) - Framework : Angular 17+ avec support i18n - Chargement : Dynamique et différé - Rendu : Canvas/SVG adaptatif - Responsive : Gestion automatique des redimensionnements - Internationalisation : Support complet français/anglais
Composant Angular Unifié
Fichier : frontend/src/app/pages/projects/components/recommendation-heatmap.component.ts
Contextes d’Utilisation
1. Création de Projet (Project Form)
<!-- Dans project-form.component.html -->
<app-recommendation-heatmap
[datasets]="previewDatasets"
[weights]="currentWeights">
</app-recommendation-heatmap>
-
Données :
DatasetScored[]
avec calculs en temps réel -
Poids : Critères configurables par l’utilisateur
-
Comportement : Prévisualisation des recommandations pendant la configuration
2. Détail de Projet (Project Detail)
<!-- Dans project-detail.component.html -->
<app-recommendation-heatmap
[datasets]="heatmapDatasets"
[weights]="heatmapWeights">
</app-recommendation-heatmap>
-
Données :
DatasetScoredWithDetails[]
avec scores pré-calculés -
Poids : Poids sauvegardés du projet
-
Comportement : Affichage des recommandations finales
Gestion Intelligente des Données
Le composant heatmap détecte automatiquement le type de données reçues :
getCriterionScore(dataset: DatasetScored, criterionName: string): number {
// Vérifie d'abord si le dataset a des scores pré-calculés (DatasetScoredWithDetails)
const datasetWithDetails = dataset as any;
if (datasetWithDetails.criterion_scores && datasetWithDetails.criterion_scores[criterionName] !== undefined) {
return datasetWithDetails.criterion_scores[criterionName];
}
// Fallback vers les calculs locaux pour la compatibilité
switch (criterionName) {
case 'ethical_score':
return this.calculateEthicalScore(dataset);
case 'technical_score':
return this.calculateTechnicalScore(dataset);
// ... autres critères
}
}
Composant Principal avec Internationalisation
@Component({
selector: 'app-recommendation-heatmap',
imports: [
CommonModule,
MatCardModule,
MatIconModule,
MatTooltipModule,
TranslateModule
],
template: `
<mat-card class="heatmap-card" *ngIf="datasets.length > 0 && activeCriteria.length > 0">
<mat-card-header>
<mat-card-title class="d-flex align-items-center">
<mat-icon class="m-r-8">insights</mat-icon>
{{ 'PROJECTS.HEATMAP.TITLE' | translate }}
</mat-card-title>
<mat-card-subtitle>
{{ 'PROJECTS.HEATMAP.SUBTITLE' | translate:{ datasets: datasets.length, criteria: activeCriteria.length } }}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="b-t-1">
<!-- État de chargement -->
<div *ngIf="isEChartsLoading" class="loading-container text-center p-20">
<mat-spinner diameter="40"></mat-spinner>
<p class="mat-body-2 m-t-12">Chargement d'Apache ECharts...</p>
</div>
<!-- Message d'erreur -->
<div *ngIf="loadingError" class="error-container text-center p-20">
<mat-icon class="icon-48 text-warn">error_outline</mat-icon>
<h4 class="mat-h4 m-t-12">Erreur de chargement</h4>
<p class="mat-body-2 text-muted">{{ loadingError }}</p>
<button mat-stroked-button (click)="retryLoading()" class="m-t-12">
<mat-icon>refresh</mat-icon>
Réessayer
</button>
</div>
<!-- Conteneur ECharts -->
<div [id]="echartsId"
style="width: 100%; height: 400px; min-height: 400px;"
*ngIf="!isEChartsLoading && !loadingError">
</div>
<!-- Informations contextuelles -->
<div *ngIf="datasets.length > 0 && !isEChartsLoading"
class="info-bar d-flex justify-content-between align-items-center m-t-16 p-12 bg-light-primary rounded">
<div class="d-flex align-items-center">
<mat-icon class="text-primary m-r-8">analytics</mat-icon>
<span class="mat-body-2">
<strong>{{ weights.length }}</strong> critères analysés •
<strong>{{ datasets.length }}</strong> datasets comparés
</span>
</div>
<div class="d-flex align-items-center">
<mat-icon class="text-primary m-r-4" style="font-size: 16px;">link</mat-icon>
<span class="mat-caption text-muted">Apache ECharts CDN</span>
</div>
</div>
</mat-card-content>
</mat-card>
`,
styleUrls: ['./recommendation-heatmap.component.scss']
})
export class RecommendationHeatmapComponent implements OnInit, OnDestroy, OnChanges {
@Input() datasets: DatasetScored[] = [];
@Input() weights: CriterionWeight[] = [];
activeCriteria: CriterionWeight[] = [];
private myChart: any = null;
isLoadingECharts = true;
componentId: string;
constructor(
@Inject(PLATFORM_ID) private platformId: Object,
private translateService: TranslateService
) {
this.componentId = Math.random().toString(36).substr(2, 9);
}
getCriterionLabel(criterionName: string): string {
const translationKey = `PROJECTS.HEATMAP.CRITERIA_LABELS.${criterionName.toUpperCase()}`;
const translated = this.translateService.instant(translationKey);
// Si la traduction n'existe pas, retourner le nom original formaté
if (translated === translationKey) {
return criterionName.replace('_', ' ').toUpperCase();
}
return translated;
}
}
Implémentation CDN Robuste
Stratégie de Chargement
/**
* Charge Apache ECharts via CDN avec gestion d'erreurs robuste
*/
private loadECharts(): Promise<any> {
return new Promise((resolve, reject) => {
// Vérification si ECharts est déjà chargé
if (typeof window !== 'undefined' && (window as any).echarts) {
console.log('ECharts déjà disponible');
resolve((window as any).echarts);
return;
}
// Création du script dynamique
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js';
script.type = 'text/javascript';
script.async = true;
// Gestionnaires d'événements
script.onload = () => {
console.log('ECharts chargé avec succès');
if ((window as any).echarts) {
resolve((window as any).echarts);
} else {
reject(new Error('ECharts non disponible après chargement'));
}
};
script.onerror = (error) => {
console.error('Erreur lors du chargement d\'ECharts:', error);
reject(new Error('Impossible de charger Apache ECharts depuis le CDN'));
};
// Timeout de sécurité (10 secondes)
setTimeout(() => {
if (!((window as any).echarts)) {
reject(new Error('Timeout lors du chargement d\'ECharts'));
}
}, 10000);
// Ajout au DOM
document.head.appendChild(script);
});
}
Initialisation Sécurisée
/**
* Initialise le composant ECharts avec gestion d'erreurs complète
*/
async ngOnInit(): Promise<void> {
if (isPlatformBrowser(this.platformId)) {
try {
await this.initializeECharts();
} catch (error) {
console.error('Erreur d\'initialisation ECharts:', error);
this.loadingError = error instanceof Error ? error.message : 'Erreur inconnue';
}
}
}
private async initializeECharts(): Promise<void> {
this.isEChartsLoading = true;
this.loadingError = null;
try {
// Chargement d'ECharts
this.echarts = await this.loadECharts();
// Initialisation du graphique
await this.initChart();
// Configuration du redimensionnement
this.setupResizeHandler();
console.log('Heatmap ECharts initialisée avec succès');
} catch (error) {
console.error('Erreur lors de l\'initialisation:', error);
throw error;
} finally {
this.isEChartsLoading = false;
}
}
Configuration Avancée de la Heatmap
Options ECharts Optimisées
/**
* Génère la configuration ECharts pour la heatmap de recommandations
*/
private getEChartsOption(): any {
const datasets = this.datasets.slice(0, 20); // Limitation pour performance
const weights = this.weights;
// Préparation des données au format [x, y, value]
const data: [number, number, number][] = [];
const yAxisData: string[] = [];
const xAxisData: string[] = [];
// Construction des axes et données
datasets.forEach((dataset, datasetIndex) => {
// Troncature des noms longs pour l'affichage
const displayName = dataset.dataset_name.length > 25
? dataset.dataset_name.substring(0, 22) + '...'
: dataset.dataset_name;
yAxisData.push(displayName);
weights.forEach((weight, weightIndex) => {
// Labels des critères (première itération seulement)
if (datasetIndex === 0) {
xAxisData.push(this.formatCriterionName(weight.criterion_name));
}
// Récupération du score pour ce dataset/critère
const score = this.getDatasetScore(dataset, weight.criterion_name);
data.push([weightIndex, datasetIndex, score]);
});
});
return {
// Configuration du tooltip interactif
tooltip: {
position: 'top',
backgroundColor: 'rgba(50, 50, 50, 0.95)',
borderColor: '#4575b4',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12
},
formatter: (params: any) => {
const dataset = datasets[params.data[1]];
const weight = weights[params.data[0]];
const score = params.data[2];
const percentage = (score * 100).toFixed(1);
// Détermination de la couleur selon le score
const scoreColor = this.getScoreColorHex(score);
return `
<div style="padding: 8px; max-width: 300px;">
<div style="margin-bottom: 8px;">
<strong style="color: #4575b4;">${dataset.dataset_name}</strong>
</div>
<div style="margin-bottom: 6px;">
<strong>${this.formatCriterionName(weight.criterion_name)}</strong>
</div>
<div style="margin-bottom: 6px;">
Score: <strong style="color: ${scoreColor};">${percentage}%</strong>
<span style="margin-left: 8px; color: #ccc;">
(Poids: ${(weight.weight * 100).toFixed(0)}%)
</span>
</div>
<hr style="margin: 6px 0; border-color: #666;">
<div style="font-size: 11px; color: #ccc;">
<div>Instances: ${dataset.instances_number?.toLocaleString() || 'N/A'}</div>
<div>Features: ${dataset.features_number || 'N/A'}</div>
${dataset.objective ? `<div>Objectif: ${dataset.objective}</div>` : ''}
</div>
</div>
`;
}
},
// Configuration de la grille
grid: {
height: '75%',
top: '5%',
left: '25%',
right: '5%',
bottom: '20%'
},
// Axe des X (critères)
xAxis: {
type: 'category',
data: xAxisData,
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
}
},
axisLabel: {
rotate: 30,
fontSize: 10,
color: '#666',
margin: 8
},
axisLine: {
lineStyle: { color: '#ccc' }
}
},
// Axe des Y (datasets)
yAxis: {
type: 'category',
data: yAxisData,
splitArea: {
show: true,
areaStyle: {
color: ['rgba(250,250,250,0.1)', 'rgba(200,200,200,0.1)']
}
},
axisLabel: {
fontSize: 10,
color: '#666',
width: 150,
overflow: 'truncate'
},
axisLine: {
lineStyle: { color: '#ccc' }
}
},
// Échelle de couleurs optimisée
visualMap: {
min: 0,
max: 1,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: '5%',
inRange: {
color: [
'#d73027', // Rouge (0-20%)
'#f46d43', // Orange-Rouge (20-40%)
'#fdae61', // Orange (40-60%)
'#fee08b', // Jaune-Orange (60-70%)
'#e6f598', // Jaune-Vert (70-80%)
'#abdda4', // Vert clair (80-85%)
'#66c2a5', // Vert (85-90%)
'#3288bd', // Bleu clair (90-95%)
'#4575b4' // Bleu foncé (95-100%)
]
},
text: ['Excellent (100%)', 'Faible (0%)'],
textStyle: {
fontSize: 10,
color: '#666'
},
itemWidth: 15,
itemHeight: 120
},
// Configuration de la série heatmap
series: [{
name: 'Scores de Recommandation',
type: 'heatmap',
data: data,
emphasis: {
itemStyle: {
shadowBlur: 15,
shadowColor: 'rgba(0, 0, 0, 0.4)',
borderColor: '#4575b4',
borderWidth: 2
}
},
label: {
show: true,
formatter: (params: any) => {
const percentage = (params.data[2] * 100).toFixed(0);
return percentage + '%';
},
fontSize: 9,
color: '#fff',
fontWeight: 'bold'
}
}],
// Configuration de l'animation
animation: true,
animationDuration: 1000,
animationEasing: 'cubicOut'
};
}
Utilitaires de Formatting
/**
* Formate les noms de critères pour l'affichage
*/
private formatCriterionName(criterionName: string): string {
const nameMap: { [key: string]: string } = {
'ethical_score': 'Éthique',
'technical_score': 'Technique',
'popularity_score': 'Popularité',
'anonymization': 'Anonymisation',
'transparency': 'Transparence',
'documentation': 'Documentation',
'data_quality': 'Qualité',
'instances_count': 'Instances',
'features_count': 'Features',
'citations': 'Citations'
};
return nameMap[criterionName] || criterionName;
}
/**
* Récupère le score d'un dataset pour un critère donné
*/
private getDatasetScore(dataset: any, criterionName: string): number {
// Mapping des scores selon le critère
switch (criterionName) {
case 'ethical_score':
return dataset.ethical_score || 0;
case 'technical_score':
return dataset.technical_score || 0;
case 'popularity_score':
return dataset.popularity_score || 0;
case 'anonymization':
return dataset.anonymization_applied ? 1 : 0;
case 'transparency':
return dataset.transparency ? 1 : 0;
case 'documentation':
return dataset.external_documentation_available ? 1 : 0;
default:
return dataset[criterionName] || 0;
}
}
/**
* Détermine la couleur hexadécimale selon le score
*/
private getScoreColorHex(score: number): string {
if (score >= 0.85) return '#4575b4'; // Bleu - Excellent
if (score >= 0.60) return '#66c2a5'; // Vert - Bon
if (score >= 0.30) return '#fdae61'; // Orange - Moyen
return '#d73027'; // Rouge - Faible
}
Gestion de la Performance
Optimisation des Données
/**
* Optimise les données pour une visualisation fluide
*/
private optimizeDataForVisualization(datasets: any[]): any[] {
// Limitation à 20 datasets maximum pour éviter la surcharge
if (datasets.length <= 20) {
return datasets;
}
console.log(`Limitation de ${datasets.length} à 20 datasets pour la visualisation`);
// Tri par score décroissant et sélection des 20 meilleurs
return datasets
.sort((a, b) => (b.score || 0) - (a.score || 0))
.slice(0, 20);
}
/**
* Debounce des mises à jour pour éviter les re-rendus excessifs
*/
private debouncedUpdate = this.debounce((datasets: any[], weights: any[]) => {
this.updateChart(datasets, weights);
}, 300);
private debounce(func: Function, wait: number): Function {
let timeout: any;
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
Gestion du Redimensionnement
/**
* Configure la gestion responsive du graphique
*/
private setupResizeHandler(): void {
if (typeof window !== 'undefined') {
const resizeHandler = () => {
if (this.chartInstance && !this.chartInstance.isDisposed()) {
// Redimensionnement avec délai pour éviter les appels excessifs
setTimeout(() => {
if (this.chartInstance && !this.chartInstance.isDisposed()) {
this.chartInstance.resize();
}
}, 100);
}
};
window.addEventListener('resize', resizeHandler);
// Stockage pour cleanup
this.resizeListener = () => {
window.removeEventListener('resize', resizeHandler);
};
}
}
Tests et Validation
Tests Unitaires
describe('RecommendationHeatmapComponent', () => {
let component: RecommendationHeatmapComponent;
let fixture: ComponentFixture<RecommendationHeatmapComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RecommendationHeatmapComponent],
imports: [
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
MatButtonModule
]
}).compileComponents();
fixture = TestBed.createComponent(RecommendationHeatmapComponent);
component = fixture.componentInstance;
});
it('should create component', () => {
expect(component).toBeTruthy();
});
it('should generate unique echarts ID', () => {
const component1 = fixture.componentInstance;
const component2 = new RecommendationHeatmapComponent(PLATFORM_ID);
expect(component1.echartsId).not.toEqual(component2.echartsId);
expect(component1.echartsId).toContain('echarts-heatmap-');
});
it('should format criterion names correctly', () => {
expect(component.formatCriterionName('ethical_score')).toBe('Éthique');
expect(component.formatCriterionName('technical_score')).toBe('Technique');
expect(component.formatCriterionName('popularity_score')).toBe('Popularité');
});
it('should calculate dataset scores correctly', () => {
const mockDataset = {
ethical_score: 0.8,
technical_score: 0.9,
popularity_score: 0.7,
anonymization_applied: true,
transparency: false
};
expect(component.getDatasetScore(mockDataset, 'ethical_score')).toBe(0.8);
expect(component.getDatasetScore(mockDataset, 'anonymization')).toBe(1);
expect(component.getDatasetScore(mockDataset, 'transparency')).toBe(0);
});
it('should handle CDN loading errors gracefully', async () => {
// Mock d'erreur de chargement
spyOn(component, 'loadECharts').and.returnValue(
Promise.reject(new Error('CDN error'))
);
await component.initializeECharts();
expect(component.loadingError).toContain('CDN error');
expect(component.isEChartsLoading).toBe(false);
});
});
Tests d’Intégration
describe('RecommendationHeatmapComponent Integration', () => {
let component: RecommendationHeatmapComponent;
let fixture: ComponentFixture<RecommendationHeatmapComponent>;
it('should render heatmap with real data', async () => {
const mockDatasets = [
{
dataset_id: '1',
dataset_name: 'Test Dataset 1',
score: 0.85,
ethical_score: 0.8,
technical_score: 0.9,
popularity_score: 0.8,
instances_number: 10000,
features_number: 25
},
{
dataset_id: '2',
dataset_name: 'Test Dataset 2',
score: 0.72,
ethical_score: 0.7,
technical_score: 0.75,
popularity_score: 0.7,
instances_number: 5000,
features_number: 15
}
];
const mockWeights = [
{ criterion_name: 'ethical_score', weight: 0.4 },
{ criterion_name: 'technical_score', weight: 0.4 },
{ criterion_name: 'popularity_score', weight: 0.2 }
];
component.datasets = mockDatasets;
component.weights = mockWeights;
await component.initializeECharts();
expect(component.chartInstance).toBeDefined();
expect(component.loadingError).toBeNull();
});
});
Déploiement et Maintenance
Configuration de Production
# Configuration ECharts pour production
ECHARTS_CDN_URL=https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js
ECHARTS_LOAD_TIMEOUT=10000
HEATMAP_MAX_DATASETS=20
HEATMAP_UPDATE_DEBOUNCE=300
Monitoring et Logging
/**
* Service de monitoring pour la visualisation ECharts
*/
@Injectable({
providedIn: 'root'
})
export class EChartsMonitoringService {
logEChartsLoad(success: boolean, loadTime: number): void {
console.log(`ECharts ${success ? 'chargé' : 'échec'} en ${loadTime}ms`);
// Envoi vers service d'analytics si configuré
if (environment.analytics) {
this.analytics.track('echarts_load', {
success,
loadTime,
timestamp: new Date().toISOString()
});
}
}
logHeatmapRender(datasetCount: number, criteriaCount: number): void {
console.log(`Heatmap rendue: ${datasetCount} datasets, ${criteriaCount} critères`);
}
}
Support d’Internationalisation (i18n)
Configuration des Traductions
La heatmap supporte maintenant l’internationalisation complète avec français et anglais.
Fichiers de traduction :
frontend/src/assets/i18n/fr.json
{
"PROJECTS": {
"HEATMAP": {
"TITLE": "Heat Map des Recommandations Apache ECharts",
"SUBTITLE": "Visualisation interactive Apache ECharts - {{datasets}} datasets sur {{criteria}} critères",
"LEGEND": {
"LOW": "Faible (0-30%)",
"MEDIUM": "Moyen (30-60%)",
"GOOD": "Bon (60-85%)",
"EXCELLENT": "Excellent (85%+)"
},
"INFO": {
"CRITERIA_ANALYZED": "{{count}} critères analysés",
"DATASETS_COMPARED": "{{count}} datasets comparés",
"CDN_POWERED": "Apache ECharts CDN"
},
"LOADING": "Chargement d'ECharts...",
"NO_DATA": "Configurez des poids pour afficher la heat map ECharts",
"CRITERIA_LABELS": {
"ETHICAL_SCORE": "Éthique",
"TECHNICAL_SCORE": "Technique",
"POPULARITY_SCORE": "Popularité",
"ANONYMIZATION": "Anonymisation",
"TRANSPARENCY": "Transparence",
"INFORMED_CONSENT": "Consentement",
"DOCUMENTATION": "Documentation",
"DATA_QUALITY": "Qualité"
}
}
}
}
frontend/src/assets/i18n/en.json
{
"PROJECTS": {
"HEATMAP": {
"TITLE": "Apache ECharts Recommendations Heat Map",
"SUBTITLE": "Interactive Apache ECharts visualization - {{datasets}} datasets on {{criteria}} criteria",
"LEGEND": {
"LOW": "Low (0-30%)",
"MEDIUM": "Medium (30-60%)",
"GOOD": "Good (60-85%)",
"EXCELLENT": "Excellent (85%+)"
},
"INFO": {
"CRITERIA_ANALYZED": "{{count}} criteria analyzed",
"DATASETS_COMPARED": "{{count}} datasets compared",
"CDN_POWERED": "Apache ECharts CDN"
},
"LOADING": "Loading ECharts...",
"NO_DATA": "Configure weights to display the ECharts heat map",
"CRITERIA_LABELS": {
"ETHICAL_SCORE": "Ethics",
"TECHNICAL_SCORE": "Technical",
"POPULARITY_SCORE": "Popularity",
"ANONYMIZATION": "Anonymization",
"TRANSPARENCY": "Transparency",
"INFORMED_CONSENT": "Consent",
"DOCUMENTATION": "Documentation",
"DATA_QUALITY": "Quality"
}
}
}
}
Implémentation des Labels Dynamiques
/**
* Récupère le label traduit d'un critère
*/
getCriterionLabel(criterionName: string): string {
const translationKey = `PROJECTS.HEATMAP.CRITERIA_LABELS.${criterionName.toUpperCase()}`;
const translated = this.translateService.instant(translationKey);
// Si la traduction n'existe pas, retourner le nom original formaté
if (translated === translationKey) {
return criterionName.replace('_', ' ').toUpperCase();
}
return translated;
}
Usage dans les Templates
<!-- Titre et sous-titre traduits avec paramètres -->
<mat-card-title>
{{ 'PROJECTS.HEATMAP.TITLE' | translate }}
</mat-card-title>
<mat-card-subtitle>
{{ 'PROJECTS.HEATMAP.SUBTITLE' | translate:{ datasets: datasets.length, criteria: activeCriteria.length } }}
</mat-card-subtitle>
<!-- Légende traduite -->
<div class="legend-item">
<div class="legend-color" style="background: #4caf50;"></div>
<span class="mat-caption">{{ 'PROJECTS.HEATMAP.LEGEND.GOOD' | translate }}</span>
</div>
<!-- Messages d'état traduits -->
<p class="mat-caption">{{ 'PROJECTS.HEATMAP.LOADING' | translate }}</p>
<p class="mat-body-1">{{ 'PROJECTS.HEATMAP.NO_DATA' | translate }}</p>
Avantages de l’Implémentation Unifiée
Performance et Maintenance
-
Code Réutilisable : Un seul composant pour deux contextes
-
Cohérence Visuelle : Même expérience utilisateur partout
-
Maintenance Simplifiée : Corrections et améliorations centralisées
-
Support i18n : Traductions cohérentes dans toute l’application
Flexibilité des Données
-
Auto-détection : Gestion automatique des types de datasets
-
Scores Pré-calculés : Support des
DatasetScoredWithDetails
-
Calculs Dynamiques : Fallback vers calculs locaux si nécessaire
-
Compatibilité : Rétrocompatible avec l’ancienne implémentation
Cette architecture garantit une visualisation robuste, performante et maintenable des recommandations de datasets avec Apache ECharts ! 🎯📊🚀