Partie 2 : Carte 2D

Partie 2 : Carte 2D

Mars 2026

Suite de l'article Globe 3D : Carte des connexions en temps réel.

Le besoin

Le globe 3D est impressionnant visuellement, mais pas toujours pratique. Quand on veut identifier précisément d'où viennent les connexions (quelle ville, quel pays), une carte 2D classique est plus lisible. L'idée : ajouter un mode 2D avec Leaflet, un toggle pour basculer entre les deux vues, et plusieurs améliorations d'UX tout en gardant une animation.

Ajouts réalisés

1. Carte 2D avec Leaflet

Leaflet.js crée une carte 2D interactive dans un conteneur #map-container, masqué par défaut. On utilise les tuiles CartoDB Dark Matter — un fond sombre sans labels intrusifs, cohérent avec le thème du globe.

const leafletMap = L.map("map-container", {
  center: [30, 10],   // Centré sur l'Europe
  zoom: 3,
  zoomControl: false,  // On le repositionne en bas à droite
  attributionControl: false
});

// Tuiles CartoDB Dark Matter
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
  maxZoom: 18,
  subdomains: "abcd"
}).addTo(leafletMap);

// Zoom repositionné (évite de chevaucher le bouton menu ☰)
L.control.zoom({ position: "bottomright" }).addTo(leafletMap);

carte2d.jpeg

Marqueurs et lignes d'attaque

Chaque source de connexion a un cercle cyan dont le rayon dépend du nombre de hits (échelle logarithmique, comme sur le globe 3D). Une polyline relie chaque source au serveur (Toulouse), avec des pointillés animés en CSS.

// Marqueur cercle — taille proportionnelle au count
const radius = Math.min(3 + Math.log2(a.count + 1) * 1.5, 12);
const marker = L.circleMarker([a.lat, a.lon], {
  radius: radius,
  fillColor: "rgba(0, 255, 255, 0.8)",
  fillOpacity: 0.7,
  color: "rgba(0, 255, 255, 0.4)",
  weight: 1
});

// Ligne d'attaque avec animation CSS
const line = L.polyline(
  [[a.lat, a.lon], [server.lat, server.lon]],
  {
    color: "rgba(0, 255, 255, 0.3)",
    weight: Math.min(1 + Math.log2(a.count + 1) * 0.3, 3),
    dashArray: "8 4",
    className: "leaflet-attack-line"  // Animation CSS
  }
);

L'animation des pointillés est purement CSS — pas besoin de JavaScript :

.leaflet-attack-line {
  stroke-dasharray: 8 4;
  animation: dash-flow 1.5s linear infinite;
}

@keyframes dash-flow {
  to { stroke-dashoffset: -24; }
}

Le serveur est représenté par un point orange fixe à Toulouse, avec un L.divIcon personnalisé (cercle orange avec ombre portée).

carte2d_europe.jpeg

Popups au clic

Chaque marqueur a un popup avec le nom du pays, la ville et le nombre de requêtes. Le style des popups est personnalisé pour rester cohérent avec le thème sombre :

.leaflet-popup-content-wrapper {
  background: rgba(10, 10, 30, 0.9) !important;
  backdrop-filter: blur(8px);
  border: 1px solid rgba(0, 255, 255, 0.3) !important;
  color: #fff !important;
}

.leaflet-popup-content strong {
  color: #0ff;  /* Noms de pays en cyan */
}

2. Toggle 2D / 3D

Deux boutons en haut à droite (3D / 2D) permettent de basculer entre les vues. La logique est simple : on ajoute/retire la classe CSS view-2d sur le <body>, et le CSS gère la visibilité des conteneurs.

function switchView(view) {
  currentView = view;
  if (view === "2d") {
    document.body.classList.add("view-2d");
    // Leaflet a besoin d'un invalidateSize quand son conteneur
    // change de display (sinon les tuiles ne se chargent pas)
    setTimeout(() => leafletMap.invalidateSize(), 100);
  } else {
    document.body.classList.remove("view-2d");
  }
  fetchData();  // Relance un fetch pour la vue active
}
/* Par défaut : globe visible, carte masquée */
#map-container { display: none; }

/* En mode 2D : carte visible, globe masqué */
body.map-page.view-2d #map-container { display: block; }
body.map-page.view-2d #globe-container { display: none; }

Piège invalidateSize() : Leaflet calcule la taille de son conteneur au moment de l'initialisation. Si le conteneur est en display: none à ce moment-là (ce qui est notre cas), Leaflet ne connaît pas ses dimensions. Quand on le rend visible, il faut appeler invalidateSize() pour qu'il recalcule — sinon les tuiles se chargent partiellement ou pas du tout. Le setTimeout(100) laisse le temps au navigateur de rendre le conteneur avant le recalcul.

carte2d_toggle.jpeg

3. Points lumineux adaptatifs au zoom

Sur le globe 3D, les points lumineux aux origines des connexions s'adaptent au niveau de zoom de la caméra. Plus on zoom, plus les points rétrécissent — comme les marqueurs Grafana Geomap.

.pointRadius(d => {
  const alt = globe.pointOfView().altitude;
  // alt ~2.2 = vue globale, ~0.5 = zoom continent, ~0.1 = zoom ville
  const zoomFactor = Math.min(alt / 2.2, 1);
  return Math.min(0.15 + Math.log2(d.count + 1) * 0.08, 0.8)
         * (0.3 + zoomFactor * 0.7);
})

Le problème : pointRadius() n'est appelé par Globe.gl qu'au moment du chargement des données. Si l'utilisateur zoome après, les points gardent leur taille initiale. La solution : écouter l'événement change des contrôles OrbitControls (three.js) et forcer Globe.gl à recalculer :

globe.controls().addEventListener("change", () => {
  const pts = globe.pointsData();
  if (pts && pts.length > 0) {
    globe.pointsData(pts);  // Re-set les mêmes données → recalcul
  }
});

C'est un "hack" : on re-set les mêmes données pour forcer le recalcul de pointRadius(). Pas très élégant, mais Globe.gl n'expose pas de méthode invalidate() pour les points.

4. Panneau repliable

Le panneau de statistiques en bas à gauche peut maintenant être replié/déplié en cliquant sur le header. Un bouton chevron indique l'état.

const statsPanel = document.getElementById("stats-panel");
const panelHeader = document.querySelector(".panel-header");

panelHeader.addEventListener("click", () => {
  statsPanel.classList.toggle("collapsed");
});

L'animation de repli utilise max-height avec une transition CSS — la technique classique pour animer un height: auto :

.panel-content {
  transition: max-height 0.3s ease, opacity 0.3s ease;
  max-height: 600px;
  opacity: 1;
  overflow: hidden;
}

#stats-panel.collapsed .panel-content {
  max-height: 0;
  opacity: 0;
}

Le chevron tourne de 180° quand le panneau est replié :

#stats-panel.collapsed #panel-toggle svg {
  transform: rotate(180deg);
}

5. Feed des dernières connexions

En plus du "Top pays", le panneau affiche un feed des connexions les plus actives. Les 8 sources avec le plus de hits sur la période sélectionnée :

function updateFeed(arcs) {
  const sorted = [...arcs]
    .sort((a, b) => b.count - a.count)
    .slice(0, MAX_FEED_ITEMS);

  feed.innerHTML = sorted
    .map(a => {
      const city = a.city ? a.city + ", " : "";
      return `<li>
        <span class="feed-location">${city}${a.country}</span>
        <span class="feed-count">${a.count}</span>
      </li>`;
    })
    .join("");
}

Le feed a sa propre scrollbar fine et discrète (4px, cyan transparent) pour ne pas surcharger visuellement le panneau.

6. Style des boutons zoom Leaflet

Les boutons zoom natifs de Leaflet (+/−) sont re-stylés pour correspondre au thème sombre :

.leaflet-control-zoom a {
  background: rgba(10, 10, 30, 0.8) !important;
  color: #fff !important;
  border-color: rgba(255, 255, 255, 0.2) !important;
}

.leaflet-control-zoom a:hover {
  background: rgba(0, 255, 255, 0.2) !important;
  color: #0ff !important;
}

Comparaison des deux vues

Aspect Globe 3D (Globe.gl) Carte 2D (Leaflet)
Rendu WebGL (GPU) SVG/Canvas (CPU)
Navigation Rotation libre + zoom Pan + zoom classique
Arcs Courbes 3D dans l'espace lignes 2D animées
Points Sphères lumineuses sur le globe Cercles SVG
Popups Tooltip au survol Popup au clic
Performance Plus lourd (three.js) Plus léger
Cas d'usage Impression visuelle, démo Analyse précise des localisations

Fichiers modifiés

Tous les changements sont dans le repo home-fonta :

Fichier Modifications
templates/map.html +conteneur #map-container, +toggle 2D/3D, +bouton chevron panneau, +import Leaflet CSS/JS
static/map.js +initialisation Leaflet, +updateLeafletMap(), +switchView(), +points adaptatifs zoom, +feed connexions, +panneau repliable
static/map.css +styles carte 2D, +toggle buttons, +popups Leaflet, +lignes animées, +panneau repliable, +responsive

Récap

Élément Détail
Vue 3D Globe.gl avec arcs animés + points adaptatifs au zoom
Vue 2D Leaflet + CartoDB Dark Matter + polylines animées CSS
Toggle Boutons 3D/2D en haut à droite, bascule via classe CSS
Panneau Repliable avec animation, feed des 8 connexions les plus actives
Popups Style dark theme cohérent (glassmorphism)
Responsive Panneau pleine largeur sur mobile

Techniques clés

  • Leaflet.js avec tuiles CartoDB Dark Matter (thème sombre)
  • Polylines animées en CSS pur (stroke-dashoffset + @keyframes)
  • invalidateSize() obligatoire après changement de display
  • Points Globe.gl recalculés au zoom via re-set des données
  • Panneau repliable avec max-height transition (animation height: auto)
  • Scrollbar personnalisée WebKit pour le feed (4px, cyan)