Architecture de monitoring
Architecture globale de Zorni
1) Tableau des valeurs de tracking
| Catégorie | Événement | Description | Type d'événement |
|---|---|---|---|
| Événements de visibilité | search_impression |
L'annonce est apparue dans les résultats de recherche (même si l'utilisateur n'a pas cliqué). | visibility_event |
publication_view |
Une annonce est affichée à l'écran. | visibility_event | |
| Événements de conversion | preview_view |
L'utilisateur est arrivé à l'écran récapitulatif. | conversion_event |
click_booking_button |
L'utilisateur a cliqué sur "Réserver". | conversion_event | |
add_to_favorites |
L'annonce a été enregistrée dans la liste de favoris. | conversion_event | |
| Événements marketing | time_spent_on_publication |
Temps passé sur la page d'une annonce (ex : > 30 secondes). | marketing_event |
bounce_event |
L'utilisateur quitte la page après seulement 2 secondes (indique souvent une photo de mauvaise qualité ou un prix trop élevé). | marketing_event | |
review_read |
L'utilisateur a lu les avis clients. | marketing_event | |
| Événements techniques | api_error_frontend |
Une erreur 4XX ou 5XX reçue par le frontEnd. | technical_event |
2) Flux d’écriture (Events)
2.0 Flux d’écriture (vue séquence)
sequenceDiagram
participant Portal as zorni-portal (Front)
participant Tracking as tracking-api (BFF / Proxy public)
participant Kafka as Kafka (tracking.events.v1)
participant Monitoring as monitoring-api (privée)
participant ES as Elasticsearch (tracking-events-v1-*)
Portal->>Tracking: GraphQL recordEvent(input)
Tracking->>Kafka: Produce event (key=organizationId:publicationId)
Kafka-->>Monitoring: Consume event
Monitoring->>ES: Index event (_id=eventId)
ES-->>Monitoring: ACK
Monitoring-->>Kafka: Commit offset
Tracking-->>Portal: 200 OK (event accepté)
2.1 Objet “Front → Tracking” (GraphQL input)
Objectif : un contrat stable, déjà compatible KPI.
{
"schemaVersion": "1.0",
"eventId": "evt_01J9... (uuid/ulid)",
"eventTime": "2026-05-01T12:34:56.789Z",
"category": "VISIBILITY_EVENT",
"eventType": "PUBLICATION_VIEW",
"organizationId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"establishmentId": "550e8400-e29b-41d4-a716-446655440000",
"publicationId": "111e8400-e29b-41d4-a716-446655441111",
"visitorCountry": "FR",
"tripPreference": "ABC1234",
"durationMs": null,
"context": {
"source": "ZORNI_PORTAL",
"page": "PUBLICATION_DETAILS"
}
}
Règles :
- durationMs est renseigné uniquement pour TIME_SPENT_ON_PUBLICATION.
2.2 Objet “Tracking → Kafka” (message canonique)
- Même contenu fonctionnel que l’input
- Topic recommandé :
tracking.events.v1 - Key recommandée :
organizationId:publicationId(répartition + ordre relatif par publication).
{
"schemaVersion": "1.0",
"eventId": "evt_01J9...",
"eventTime": "2026-05-01T12:34:56.789Z",
"category": "VISIBILITY_EVENT",
"eventType": "PUBLICATION_VIEW",
"organizationId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"establishmentId": "550e8400-e29b-41d4-a716-446655440000",
"publicationId": "111e8400-e29b-41d4-a716-446655441111",
"visitorCountry": "FR",
"tripPreference": "ABC1234",
"sessionId": "s_2b7a...",
"durationMs": null,
"context": {
"source": "ZORNI_PORTAL",
"page": "PUBLICATION_DETAILS"
}
}
2.3 Objet “Monitoring → Elasticsearch” (document indexé)
Le document ES correspond au message Kafka (mêmes champs) avec la règle d’idempotence :
- _id = eventId
3) Flux de lecture (Dashboard)
sequenceDiagram
participant Portal as zorni-portal (Dashboard)
participant Tracking as tracking-api (BFF)
participant Monitoring as monitoring-api (privée)
participant ES as Elasticsearch
Portal->>Tracking: GraphQL query dashboardSummary(...)
Tracking->>Monitoring: REST GET /summary?... (interne)
Monitoring->>ES: Query + aggregations (filters + aggs)
ES-->>Monitoring: Aggregations result
Monitoring-->>Tracking: partnerSummary JSON
Tracking-->>Portal: partnerSummary JSON
3.1 Exemple de réponse API (query de consultation)
Exemple de payload retourné par l’API lors d’une query de consultation du dashboard :
{
"partnerSummary": {
"organizationId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"establishmentId": "550e8400-e29b-41d4-a716-446655440000",
"publicationId": "111e8400-e29b-41d4-a716-446655441111",
"tripPreference": "ABC1234",
"period": "last_30_days",
"generatedAt": "2026-01-05T18:30:00Z",
"stats": {
"funnel": [
{ "step": "Impressions", "value": 12500 },
{ "step": "Vues", "value": 3200 },
{ "step": "Pré-réservations", "value": 450 },
{ "step": "Réservations", "value": 85 }
],
"engagement": {
"bounceRate": 18.5,
"avgTimeSpent": 45,
"favoritesCount": 124,
"reviewReadRate": 78
},
"evolution": [
{ "date": "2025-08", "visibility": 2100, "marketing": 1100, "conversion": 150 },
{ "date": "2025-09", "visibility": 2500, "marketing": 1300, "conversion": 180 },
{ "date": "2025-10", "visibility": 2800, "marketing": 1400, "conversion": 210 },
{ "date": "2025-11", "visibility": 3200, "marketing": 1800, "conversion": 240 },
{ "date": "2025-12", "visibility": 4500, "marketing": 2500, "conversion": 320 },
{ "date": "2026-01", "visibility": 5200, "marketing": 2900, "conversion": 410 }
],
"geoDistribution": [
{ "country": "FR", "count": 1500, "percentage": 52 },
{ "country": "US", "count": 800, "percentage": 28 },
{ "country": "MA", "count": 600, "percentage": 20 }
]
}
}
}
4) Index fonctionnel Elasticsearch
4.1 Nom d’index
Recommandation :
- Data Stream : tracking-events-v1
4.2 Mapping minimal (principes)
Objectif : rendre les champs filtrables/agrégeables sans explosion de mapping.
- eventTime : date
- IDs : keyword
- enums : keyword
- durationMs : long
- visitorCountry : keyword
- context : limiter (préférer champs whitelistes) pour éviter la “mapping explosion”
Exemple (pseudo-template) :
{
"index_patterns": ["tracking-events-v1-*"],
"template": {
"mappings": {
"dynamic": "strict",
"properties": {
"schemaVersion": { "type": "keyword" },
"eventId": { "type": "keyword" },
"eventTime": { "type": "date" },
"category": { "type": "keyword" },
"eventType": { "type": "keyword" },
"organizationId": { "type": "keyword" },
"establishmentId": { "type": "keyword" },
"publicationId": { "type": "keyword" },
"tripPreference": { "type": "keyword" },
"visitorCountry": { "type": "keyword" },
"durationMs": { "type": "long" },
"context": {
"properties": {
"source": { "type": "keyword" },
"page": { "type": "keyword" }
}
}
}
}
}
}
4.3 Rétention (ILM)
Conserver 180 jours, avec rollover par taille/temps (à calibrer selon volume). - Hot phase (écriture + query) - Delete phase (suppression)
5) Les graphes du dashboard
5.1 Funnel de conversion (funnel)
Chaque étape du funnel correspond à un comptage d'un eventType spécifique :
- Impressions : compter tous les documents où
eventType = search_impression. - Vues : compter tous les documents où
eventType = publication_view. - Pré-réservations : compter tous les documents où
eventType = preview_view. - Réservations : compter tous les documents où
eventType = click_booking_button.
5.2 Métriques d'engagement (engagement)
bounceRate (taux de rebond)
- Logique : ratio de sessions où l'utilisateur n'a fait qu'un seul événement
publication_viewsans interaction. - Collecte : calculé au moment de la visite si l'utilisateur reste moins de 10 s.
- Calcul :
(nombre de bounce / total des visites) * 100. avgTimeSpent: moyenne de la valeur contenue dans le champduration(à renseigner viatime_spent_on_publication).favoritesCount: nombre total d'événements oùeventType = add_to_favorites.
reviewReadRate
- Calcul :
(nombre d'événements review_read / nombre de publication_view) * 100.
5.3 Évolution temporelle (evolution)
Ici, vous utilisez une agrégation Elasticsearch de type date_histogram (par mois ou par jour) avec des filtres internes :
visibility: somme des événements dontcategory = visibility_event.marketing: somme des événements dontcategory = marketing_event.conversion: somme des événements dontcategory = conversion_event.
5.4 Distribution géographique (geoDistribution)
country: agrégationtermssurvisitorCountry.count: nombre de documents trouvés pour ce code pays.percentage:(count du pays / total) * 100.
Nous allons utiliser le client officiel Elasticsearch Java Client (le successeur du RestHighLevelClient) pour construire cette requête de manière typée et robuste.
Voici comment implémenter le service Java qui va générer les données pour votre API Monitoring.
- Dépendance Maven (pom.xml)
Assurez-vous d'avoir le client Java récent :
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.x.x</version>
</dependency>
- Le Service Java Spring Boot
Voici le code pour construire la requête d'agrégation et transformer la réponse en votre objet JSON :
@Service
public class MonitoringService {
@Autowired
private ElasticsearchClient esClient;
public PartnerSummary getMonitoringStats(String orgId, String estId) throws IOException {
// 1. Définition de la recherche avec agrégations
SearchResponse<Void> response = esClient.search(s -> s
.index("logs-tracking-*") // Votre index de logs
.size(0) // On ne veut pas les documents, juste les stats
.query(q -> q
.bool(b -> b
.filter(f -> f.term(t -> t.field("organizationId").value(orgId)))
.filter(f -> f.term(t -> t.field("establishmentId").value(estId)))
.filter(f -> f.range(r -> r.field("eventTime").gte(JsonData.of("now-6M/M"))))
)
)
.aggregations("evolution_temporelle", a -> a
.dateHistogram(h -> h.field("eventTime").calendarInterval(CalendarInterval.Month).format("yyyy-MM"))
.aggregations("visibility", sub -> sub.filter(f -> f.term(t -> t.field("category").value("visibility_event"))))
.aggregations("marketing", sub -> sub.filter(f -> f.term(t -> t.field("category").value("marketing_event"))))
.aggregations("conversion", sub -> sub.filter(f -> f.term(t -> t.field("category").value("conversion_event"))))
)
.aggregations("provenance_geo", a -> a
.terms(t -> t.field("visitorCountry.keyword").size(10))
),
Void.class
);
// 2. Transformation du résultat pour votre JSON
return mapToPartnerSummary(response, orgId, estId);
}
private PartnerSummary mapToPartnerSummary(SearchResponse<Void> response, String orgId, String estId) {
List<EvolutionData> evolution = new ArrayList<>();
// Extraction de l'histogramme
response.aggregations().get("evolution_temporelle").dateHistogram().buckets().array().forEach(bucket -> {
evolution.add(new EvolutionData(
bucket.keyAsString(),
bucket.aggregations().get("visibility").filter().docCount(),
bucket.aggregations().get("marketing").filter().docCount(),
bucket.aggregations().get("conversion").filter().docCount()
));
});
// Extraction de la géo-distribution
List<GeoData> geoDist = response.aggregations().get("provenance_geo").sterms().buckets().array().stream()
.map(b -> new GeoData(b.key().stringValue(), b.docCount()))
.collect(Collectors.toList());
// Construction de l'objet final (simplifié pour l'exemple)
return new PartnerSummary(orgId, estId, evolution, geoDist);
}
}
5.5 Graphe de tunnel de conversion (Funnel Chart)
C'est le graphique maître du dashboard. Il permet au partenaire de comprendre où il perd des clients potentiels.
Graphe de tunnel de conversion
- Données à récupérer : le compte (
count) des événements groupés pareventType. - Filtre :
organizationIdouestablishmentId. - Visuel : un entonnoir descendant.
| Étape | Événement cible | Formule de calcul |
|---|---|---|
| 1. Portée | search_impression |
Somme totale des impressions. |
| 2. Intérêt | publication_view |
(Total vues / Total impressions) × 100 = CTR |
| 3. Intention | preview_view |
(Total previews / Total vues) × 100 |
| 4. Action | click_booking_button |
(Total clics / Total vues) × 100 = Taux de conversion |
Exemple d'implémentation (Recharts)
Voici comment mettre en place le graphique en entonnoir en utilisant la bibliothèque Recharts :
-
Installation des dépendances
-
Implémentation technique Recharts propose un composant FunnelChart. Il est crucial pour visualiser la déperdition entre l'impression et la réservation.
import { FunnelChart, Funnel, LabelList, Tooltip, ResponsiveContainer } from 'recharts';
const funnelData = [
{ value: 12500, name: 'Impressions', fill: '#8884d8' },
{ value: 3200, name: 'Vues', fill: '#83a6ed' },
{ value: 450, name: 'Aperçus', fill: '#8dd1e1' },
{ value: 85, name: 'Réservations', fill: '#82ca9d' },
];
// Dans votre composant :
<ResponsiveContainer width="100%" height={300}>
<FunnelChart>
<Tooltip />
<Funnel dataKey="value" data={funnelData} isAnimationActive>
<LabelList position="right" fill="#000" stroke="none" dataKey="name" />
</Funnel>
</FunnelChart>
</ResponsiveContainer>
5.6 Graphique "Avis clients consultés" (Donut Chart)
Ce graphique correspond à votre deuxième image. Il montre le pourcentage d'utilisateurs engagés ayant lu les avis.
Graphique "Avis clients consultés"
import React from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
const AvisConsultes = ({ pourcentage = 78 }) => {
const data = [
{ name: 'Engagés', value: pourcentage },
{ name: 'Autres', value: 100 - pourcentage },
];
// Couleurs basées sur votre visuel
const COLORS = ['#1e40af', '#3b82f6'];
return (
<div className="bg-slate-50 p-6 rounded-xl shadow-sm border border-slate-100 max-w-sm text-center">
<h3 className="text-lg font-bold text-slate-800 mb-4">Avis Clients Consultés</h3>
<div className="relative h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
innerRadius={70}
outerRadius={90}
paddingAngle={0}
dataKey="value"
startAngle={90}
endAngle={450}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} stroke="none" />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
{/* Texte central */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-extrabold text-slate-800">{pourcentage}%</span>
<span className="text-sm text-slate-500">Clients Engagés</span>
</div>
</div>
<p className="mt-4 text-sm text-green-600 font-medium">
Félicitations ! Les visiteurs lisent vos retours.
</p>
</div>
);
};
5.7 Graphique "Qualité de l'annonce" (Gauge Chart)
Ce graphique correspond à l'affichage du Taux de Rebond.
Graphique "Qualité de l'Annonce"
import React from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
const QualiteAnnonce = ({ tauxRebond = 18.5 }) => {
// Configuration de la jauge (Vert -> Orange -> Rouge)
const data = [
{ value: 33, color: '#22c55e' }, // Vert
{ value: 33, color: '#f97316' }, // Orange
{ value: 34, color: '#ef4444' }, // Rouge
];
return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 max-w-sm text-center">
<h3 className="text-lg font-bold text-slate-800 mb-2">Qualité de l'Annonce</h3>
<div className="relative h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="100%"
startAngle={180}
endAngle={0}
innerRadius={60}
outerRadius={80}
paddingAngle={0}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
{/* Valeur centrale */}
<div className="absolute inset-0 flex flex-col items-center justify-end pb-4">
<span className="text-4xl font-extrabold text-slate-800">{tauxRebond}%</span>
<span className="text-sm text-slate-500 font-medium uppercase tracking-wider">Taux de Rebond</span>
</div>
</div>
<div className="mt-8 border-t pt-4">
<span className="text-slate-400 font-bold block mb-1">Engagement</span>
<p className="text-sm text-green-600 font-medium">
<span className="font-bold">Excellent !</span> Les visiteurs restent sur votre annonce.
</p>
</div>
</div>
);
};
Explications pour l'intégration :
- Données dynamiques : vous pouvez passer les valeurs 78% ou 18.5% via des props récupérées depuis votre API Monitoring (dans un
useEffect). - Formatage : les composants sont enveloppés dans
ResponsiveContainerpour s'adapter à la taille des colonnes du dashboard. - Style : les couleurs CSS (#1e40af, #22c55e, etc.) reprennent les tons professionnels des captures d'écran.
5.8 Graphique "Temps passé sur l'annonce"
Ce graphique est une barre de progression segmentée avec un curseur (pointer). C'est un excellent moyen de visualiser une moyenne par rapport à des seuils de performance.
Graphique Temps Passé sur Annonce
Voici comment l'implémenter de manière élégante dans votre projet React :
Structure du composant TempsSurAnnonce Nous allons utiliser une approche "Custom CSS" avec Tailwind pour créer la barre tricolore et placer le curseur dynamiquement.
import React from 'react';
const TempsSurAnnonce = ({ secondes = 45 }) => {
// Calcul de la position du curseur en pourcentage (max 60s pour l'échelle)
const position = Math.min((secondes / 60) * 100, 100);
// Déterminer le message en fonction du temps
const getMessage = () => {
if (secondes > 30) return "Excellent ! Votre contenu retient leur attention.";
if (secondes >= 10) return "Bon intérêt, mais peut être amélioré.";
return "Attention, le temps de lecture est trop faible.";
};
return (
<div className="bg-slate-50 p-6 rounded-xl shadow-sm border border-slate-100 max-w-lg">
<h3 className="text-lg font-bold text-slate-800 mb-1">Temps Passé sur l'Annonce</h3>
<p className="text-sm text-slate-500 mb-8">Durée Moyenne Passée</p>
<div className="relative pt-6 pb-2">
{/* Curseur (Aiguille) */}
<div
className="absolute top-0 flex flex-col items-center transition-all duration-1000 ease-out"
style={{ left: `${position}%`, transform: 'translateX(-50%)' }}
>
<div className="w-0.5 h-8 bg-slate-800"></div>
<div className="w-2 h-2 rounded-full bg-slate-800 -mt-1"></div>
<span className="text-sm font-bold text-slate-800 mt-1 whitespace-nowrap">
{secondes} secondes
</span>
</div>
{/* Barre segmentée */}
<div className="flex h-8 w-full rounded-sm overflow-hidden mt-12">
{/* Faible < 10s */}
<div className="w-[16.6%] bg-red-500 h-full border-r border-white/20"></div>
{/* Intérêt 10-30s */}
<div className="w-[33.4%] bg-orange-500 h-full border-r border-white/20"></div>
{/* Élevé > 30s */}
<div className="w-[50%] bg-green-500 h-full"></div>
</div>
{/* Légendes sous la barre */}
<div className="flex justify-between mt-2 text-[10px] font-bold uppercase tracking-tighter">
<span className="text-red-600 w-[16.6%]">< 10s (Faible)</span>
<span className="text-orange-600 w-[33.4%] text-center">10-30s (Intérêt)</span>
<span className="text-green-600 w-[50%] text-right">> 30s (Élevé)</span>
</div>
</div>
<p className="mt-8 text-center text-sm text-slate-600 font-medium italic">
{getMessage()}
</p>
</div>
);
};
export default TempsSurAnnonce;
5.9 Graphique "Activité semestrielle"
Pour le graphique d'activité semestrielle (aires empilées) dans React, nous allons utiliser Recharts.
Graphique Activité Semestrielle
import React from 'react';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Legend
} from 'recharts';
const ActiviteSemestrielle = ({ data }) => {
return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 w-full h-[400px]">
<h3 className="text-lg font-bold text-slate-800 mb-6">Activité Semestrielle</h3>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
<defs>
{/* Dégradés pour un rendu plus moderne */}
<linearGradient id="colorVis" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
/>
<Tooltip
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
/>
<Legend verticalAlign="top" height={36}/>
{/* Couche Marketing (Base) */}
<Area
type="monotone"
dataKey="marketing"
stackId="1"
stroke="#94a3b8"
fill="#cbd5e1"
name="Événements Marketing"
/>
{/* Couche Visibilité (Milieu) */}
<Area
type="monotone"
dataKey="visibility"
stackId="1"
stroke="#3b82f6"
fill="url(#colorVis)"
name="Visibilité de l'Annonce"
/>
{/* Couche Conversion (Sommet) */}
<Area
type="monotone"
dataKey="conversion"
stackId="1"
stroke="#1e40af"
fill="#1e3a8a"
name="Conversions"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default ActiviteSemestrielle;
5.10 Graphique "Provenance internationale" (carte du monde interactive)
Pour intégrer le graphique de provenance internationale (carte du monde interactive), la solution la plus robuste et la plus simple pour React est d'utiliser react-simple-maps.
Graphique de provenance internationale
Voici le code pour transformer vos données de geoDistribution en une carte visuelle.
import React from "react";
import {
ComposableMap,
Geographies,
Geography,
Sphere,
Graticule
} from "react-simple-maps";
import { scaleLinear } from "d3-scale";
// URL pour les données géographiques (pays)
const geoUrl = "https://raw.githubusercontent.com/lotusms/world-map-data/main/world.json";
const ProvenanceInternationale = ({ data }) => {
// data est attendu sous la forme : [{ country: "FR", count: 1500 }, ...]
// Création d'une échelle de couleur pour l'intensité du bleu
const colorScale = scaleLinear()
.domain([0, Math.max(...data.map((d) => d.count))])
.range(["#cbd5e1", "#1e3a8a"]); // Du gris clair au bleu foncé
return (
<div className="bg-white p-6 rounded-xl shadow-md border border-slate-100 w-full h-[500px]">
<div className="mb-4">
<h3 className="text-xl font-bold text-slate-800">Provenance Internationale</h3>
<p className="text-sm text-slate-500">Évolution des Événements (7 jours)</p>
</div>
<div className="w-full h-full">
<ComposableMap
projectionConfig={{ rotate: [-10, 0, 0], scale: 147 }}
width={800}
height={400}
>
<Sphere stroke="#f1f5f9" strokeWidth={0.5} />
<Graticule stroke="#f1f5f9" strokeWidth={0.5} />
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => {
// On cherche si le pays actuel est présent dans notre JSON d'API
const d = data.find((s) => s.country === geo.properties.ISO_A2);
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={d ? colorScale(d.count) : "#f1f5f9"}
stroke="#ffffff"
strokeWidth={0.5}
style={{
default: { outline: "none" },
hover: { fill: "#3b82f6", outline: "none" }, // Bleu au survol
pressed: { outline: "none" }
}}
/>
);
})
}
</Geographies>
</ComposableMap>
</div>
{/* Légende rapide */}
<div className="flex justify-end items-center gap-2 mt-2">
<span className="text-xs text-slate-400">0 Vues</span>
<div className="w-32 h-2 rounded-full bg-gradient-to-r from-slate-200 to-blue-900"></div>
<span className="text-xs text-slate-400">Maximum</span>
</div>
</div>
);
};
export default ProvenanceInternationale;