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

View File

@@ -0,0 +1,95 @@
import type { PeriodStats } from '../services/DataService';
interface StatsPanelProps {
stats: PeriodStats | null;
loading: boolean;
periodDays: number;
}
const MEDALS = ['🥇', '🥈', '🥉'];
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 space-y-1">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">{label}</p>
<p className="text-[#d4a843] text-2xl font-bold tabular-nums">{value}</p>
{sub && <p className="text-[#6b7280] text-xs">{sub}</p>}
</div>
);
}
export function StatsPanel({ stats, loading, periodDays }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
return (
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
{/* Header */}
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
Ğ
</div>
<div>
<h1 className="text-white font-bold text-lg leading-none">ĞéoFlux</h1>
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
</div>
</div>
{/* Period label */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
Période : <span className="text-[#6b7280]">{periodLabel}</span>
</p>
{/* Stats */}
{loading ? (
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
))}
</div>
) : stats ? (
<div className="space-y-3">
<StatCard
label="Volume total"
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
/>
<StatCard
label="Transactions"
value={stats.transactionCount.toLocaleString('fr-FR')}
sub={`${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`}
/>
</div>
) : null}
{/* Top cities */}
{!loading && stats && stats.topCities.length > 0 && (
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest border-t border-[#1e1f2a] pt-3">
Top villes
</p>
{stats.topCities.map((city, i) => (
<div
key={city.name}
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex items-center gap-3"
>
<span className="text-base">{MEDALS[i]}</span>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{city.name}</p>
<p className="text-[#6b7280] text-xs">{city.count} tx</p>
</div>
<span className="text-[#d4a843] text-sm font-mono shrink-0">
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
</span>
</div>
))}
</div>
)}
{/* Footer */}
<div className="mt-auto pt-4 border-t border-[#1e1f2a]">
<p className="text-[#2e2f3a] text-xs text-center">
Données simulées · API Subsquid prête
</p>
</div>
</aside>
);
}