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);

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

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.

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 dedisplay- Points Globe.gl recalculés au zoom via re-set des données
- Panneau repliable avec
max-heighttransition (animationheight: auto) - Scrollbar personnalisée WebKit pour le feed (4px, cyan)