Skip to content

Architecture de monitoring

Architecture 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_view sans 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 champ duration (à renseigner via time_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 dont category = visibility_event.
  • marketing : somme des événements dont category = marketing_event.
  • conversion : somme des événements dont category = conversion_event.

5.4 Distribution géographique (geoDistribution)

  • country : agrégation terms sur visitorCountry.
  • 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.

Architecture monitoring

Graphe de tunnel de conversion

  • Données à récupérer : le compte (count) des événements groupés par eventType.
  • Filtre : organizationId ou establishmentId.
  • 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 :

  1. Installation des dépendances

    npm install recharts # Pour la flexibilité totale
    # OU
    npm install @tremor/react # Pour des composants dashboard tout prêts
    

  2. 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

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é Annonce

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 ResponsiveContainer pour 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

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%]">&lt; 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">&gt; 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

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

Graphique de provenance internationale

Voici le code pour transformer vos données de geoDistribution en une carte visuelle.

npm install react-simple-maps d3-scale
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;