feat: initialisation de ĞéoFlux — visualisation géographique Ğ1

- Carte Leaflet plein écran avec heatmap (OpenStreetMap, dark mode)
- Sélecteur de période 24h / 7j / 30j
- Panneau latéral : volume total, compteur de transactions, top 3 villes
- mockData.ts : 2 400 transactions simulées sur 24 villes FR/EU
- DataService.ts : abstraction prête pour branchement Subsquid/Ğ1v2
- Schémas Zod (g1.schema.ts) : validation runtime Duniter GVA + Cesium+
- Adaptateurs DuniterAdapter et CesiumAdapter (Ğ1v1, à migrer v2)
- Suite de tests Vitest : 43 tests, conformité schéma Ğ1 vérifiée

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-22 15:49:01 +01:00
commit d20d042bca
34 changed files with 6397 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet.heat';
import type { Transaction } from '../data/mockData';
// Leaflet default marker fix (Vite asset pipeline)
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconShadowUrl from 'leaflet/dist/images/marker-shadow.png';
L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl });
interface HeatMapProps {
transactions: Transaction[];
}
const HEAT_OPTIONS: L.HeatMapOptions = {
radius: 30,
blur: 22,
maxZoom: 12,
max: 1.0,
minOpacity: 0.25,
gradient: {
0.0: '#0d0221',
0.2: '#1a0a4a',
0.4: '#3a0e82',
0.55: '#7b1fa2',
0.7: '#e65100',
0.85: '#ff8f00',
1.0: '#ffd740',
},
};
export function HeatMap({ transactions }: HeatMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const heatRef = useRef<L.HeatLayer | null>(null);
// Initialize map once
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, {
center: [46.8, 2.35],
zoom: 6,
zoomControl: false,
attributionControl: true,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 18,
}).addTo(map);
L.control.zoom({ position: 'bottomright' }).addTo(map);
const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map);
mapRef.current = map;
heatRef.current = heat;
return () => {
map.remove();
mapRef.current = null;
heatRef.current = null;
};
}, []);
// Update heatmap data when transactions change
useEffect(() => {
if (!heatRef.current || !mapRef.current) return;
// Normalize amounts for intensity (log scale feels better visually)
const maxAmount = Math.max(...transactions.map((t) => t.amount), 1);
const points: L.HeatLatLngTuple[] = transactions.map((tx) => [
tx.lat,
tx.lng,
Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1),
]);
// Guard: only update if the heat layer is still attached to the map
try {
heatRef.current.setLatLngs(points);
} catch {
// map was torn down (React StrictMode double-invoke), ignore
}
}, [transactions]);
return (
<div
ref={containerRef}
className="w-full h-full"
style={{ minHeight: 0 }}
/>
);
}