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:
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
interface PeriodSelectorProps {
|
||||
value: number;
|
||||
onChange: (days: number) => void;
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
{ label: '24h', days: 1 },
|
||||
{ label: '7 jours', days: 7 },
|
||||
{ label: '30 jours', days: 30 },
|
||||
];
|
||||
|
||||
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||
return (
|
||||
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1">
|
||||
{PERIODS.map(({ label, days }) => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => onChange(days)}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${value === days
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user