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

114
src/data/mockData.ts Normal file
View File

@@ -0,0 +1,114 @@
export interface Transaction {
id: string;
timestamp: number; // Unix ms
lat: number;
lng: number;
amount: number; // Ğ1
city: string;
fromKey: string;
toKey: string;
}
// French + European cities where Ğ1 is used
const CITIES: { name: string; lat: number; lng: number; weight: number }[] = [
{ name: 'Paris', lat: 48.8566, lng: 2.3522, weight: 12 },
{ name: 'Lyon', lat: 45.7640, lng: 4.8357, weight: 9 },
{ name: 'Bordeaux', lat: 44.8378, lng: -0.5792, weight: 8 },
{ name: 'Toulouse', lat: 43.6047, lng: 1.4442, weight: 8 },
{ name: 'Montpellier', lat: 43.6108, lng: 3.8767, weight: 7 },
{ name: 'Nantes', lat: 47.2184, lng: -1.5536, weight: 6 },
{ name: 'Rennes', lat: 48.1173, lng: -1.6778, weight: 6 },
{ name: 'Grenoble', lat: 45.1885, lng: 5.7245, weight: 5 },
{ name: 'Marseille', lat: 43.2965, lng: 5.3698, weight: 7 },
{ name: 'Strasbourg', lat: 48.5734, lng: 7.7521, weight: 4 },
{ name: 'Lille', lat: 50.6292, lng: 3.0573, weight: 4 },
{ name: 'Rouen', lat: 49.4432, lng: 1.0993, weight: 3 },
{ name: 'Clermont-Ferrand', lat: 45.7772, lng: 3.0870, weight: 4 },
{ name: 'Tours', lat: 47.3941, lng: 0.6848, weight: 3 },
{ name: 'Poitiers', lat: 46.5802, lng: 0.3404, weight: 3 },
{ name: 'Besançon', lat: 47.2378, lng: 6.0241, weight: 3 },
{ name: 'Caen', lat: 49.1829, lng: -0.3707, weight: 2 },
{ name: 'Nice', lat: 43.7102, lng: 7.2620, weight: 4 },
{ name: 'Barcelone', lat: 41.3851, lng: 2.1734, weight: 3 },
{ name: 'Bruxelles', lat: 50.8503, lng: 4.3517, weight: 3 },
{ name: 'Genève', lat: 46.2044, lng: 6.1432, weight: 2 },
{ name: 'Saint-Étienne', lat: 45.4397, lng: 4.3872, weight: 3 },
{ name: 'Dijon', lat: 47.3220, lng: 5.0415, weight: 3 },
{ name: 'Angers', lat: 47.4784, lng: -0.5632, weight: 2 },
];
function randomBetween(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
function weightedRandom<T extends { weight: number }>(items: T[]): T {
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
let rand = Math.random() * totalWeight;
for (const item of items) {
rand -= item.weight;
if (rand <= 0) return item;
}
return items[items.length - 1];
}
function generateKey(): string {
const chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
return Array.from({ length: 44 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
const now = Date.now();
const transactions: Transaction[] = [];
for (let i = 0; i < count; i++) {
const city = weightedRandom(CITIES);
// Add slight spatial noise around city center
const lat = city.lat + randomBetween(-0.08, 0.08);
const lng = city.lng + randomBetween(-0.12, 0.12);
const amount = Math.round(randomBetween(0.5, 150) * 100) / 100;
transactions.push({
id: `tx-${i}-${Math.random().toString(36).slice(2)}`,
timestamp: Math.floor(now - Math.random() * maxAgeMs),
lat,
lng,
amount,
city: city.name,
fromKey: generateKey(),
toKey: generateKey(),
});
}
return transactions.sort((a, b) => b.timestamp - a.timestamp);
}
// Pre-generate a pool of 30 days worth of transactions
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
export function getTransactionsForPeriod(periodDays: number): Transaction[] {
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
return TRANSACTION_POOL.filter((tx) => tx.timestamp >= cutoff);
}
export function computeStats(transactions: Transaction[]) {
const totalVolume = transactions.reduce((sum, tx) => sum + tx.amount, 0);
const transactionCount = transactions.length;
const cityVolumes: Record<string, { volume: number; count: number }> = {};
for (const tx of transactions) {
if (!cityVolumes[tx.city]) {
cityVolumes[tx.city] = { volume: 0, count: 0 };
}
cityVolumes[tx.city].volume += tx.amount;
cityVolumes[tx.city].count += 1;
}
const topCities = Object.entries(cityVolumes)
.sort((a, b) => b[1].volume - a[1].volume)
.slice(0, 3)
.map(([name, data]) => ({ name, ...data }));
return { totalVolume, transactionCount, topCities };
}
export type { };