Files
g1flux/src/components/StatsPanel.tsx
T
syoul 8e396cd331
ci/woodpecker/push/woodpecker Pipeline was successful
feat: nature des échanges — catégorisation et détail des commentaires de transactions
- Nouveau commentParser.ts : ~80 règles regex multilingues, 11 catégories
- SubsquidAdapter : fetch du champ comment.remark depuis SubSquid
- Transaction et TransactionArc : champs comment et category
- StatsPanel : section Nature des échanges avec barres cliquables (détail inline)
- FlowMap : tooltip au survol des arcs avec répartition catégories + commentaires
- InfoPanel mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:29:59 +02:00

403 lines
18 KiB
TypeScript

import { useRef, useState } from 'react';
import type { PeriodStats } from '../services/DataService';
import type { FlowStats } from '../data/arcData';
import { Sparkline } from './Sparkline';
import { ServiceStatusDots } from './ServiceStatusDots';
import { useServiceStatus } from '../hooks/useServiceStatus';
import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser';
import type { Transaction } from '../data/mockData';
interface StatsPanelProps {
stats: PeriodStats | null;
loading: boolean;
periodDays: number;
source: 'live' | 'mock';
className?: string;
currentUD: number;
animationLabel?: string;
viewMode?: 'heatmap' | 'flow';
flowStats?: FlowStats | null;
focusCity?: string | null;
onClose?: () => void;
onEndpointChange?: () => void;
allTimestamps?: number[];
transactions?: Transaction[];
}
const MEDALS = ['🥇', '🥈', '🥉'];
function StatCard({ label, value, sub, delta }: { label: string; value: string; sub?: string; delta?: 'up' | 'down' | null }) {
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}
{delta === 'up' && <span className="text-emerald-400 text-sm ml-1.5"></span>}
{delta === 'down' && <span className="text-red-400 text-sm ml-1.5"></span>}
</p>
{sub && <p className="text-[#6b7280] text-xs">{sub}</p>}
</div>
);
}
function formatDU(g1: number, ud: number): string {
const du = g1 / ud;
if (du < 10) return `${du.toFixed(2)} DU`;
if (du < 100) return `${du.toFixed(1)} DU`;
return `${Math.round(du).toLocaleString('fr-FR')} DU`;
}
function CityRow({ city, volume, count, countryCode, accent }: {
city: string; volume: number; count: number; countryCode: string; accent?: string;
}) {
return (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-white text-xs font-medium truncate">
{countryCode && (
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none shrink-0">
{countryCode}
</span>
)}
<span className="truncate">{city}</span>
</span>
<span className={`text-xs font-mono shrink-0 ml-1 ${accent ?? 'text-[#d4a843]'}`}>
{volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
<span className="text-[#4b5563] ml-0.5">· {count}</span>
</span>
</div>
);
}
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
const { subsquid, cesium, recheck } = useServiceStatus();
const [openCategory, setOpenCategory] = useState<TxCategory | null>(null);
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
// Calcule le delta d'une valeur par rapport au refresh précédent
function delta(current: number, prevMap: Map<string, number>, key: string) {
const prev = prevMap.get(key);
if (prev === undefined) return null;
if (current > prev) return <span className="text-emerald-400 text-xs ml-1"></span>;
if (current < prev) return <span className="text-red-400 text-xs ml-1"></span>;
return null;
}
// Construit une map volume précédent par ville
const prevCityVolume = new Map(
(prevStats.current?.topCities ?? []).map((c) => [c.name, c.volume])
);
const prevVolume = prevStats.current?.totalVolume ?? null;
const prevTxCount = prevStats.current?.transactionCount ?? null;
// Mémorise les stats après le rendu
if (stats && !loading) prevStats.current = stats;
return (
<aside className={`flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto ${className ?? 'w-72 shrink-0'}`}>
{/* 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">
Ğ1Flux
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
</h1>
<ServiceStatusDots
subsquid={subsquid}
cesium={cesium}
onEndpointChange={() => { recheck(); onEndpointChange?.(); }}
/>
</div>
{onClose && (
<button
onClick={onClose}
className="ml-auto text-[#4b5563] hover:text-white transition-colors p-1 text-lg leading-none"
aria-label="Fermer"
>
</button>
)}
</div>
{/* Description */}
<p className="text-[#6b7280] text-xs leading-relaxed border-t border-[#1e1f2a] pt-3">
Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
</p>
{/* Period label + sparkline */}
<div className="border-t border-[#1e1f2a] pt-3 space-y-2">
<p className="text-[#4b5563] text-xs">
{animationLabel
? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></>
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
}
</p>
{!animationLabel && allTimestamps.length > 0 && (
<Sparkline timestamps={allTimestamps} periodDays={periodDays} />
)}
</div>
{/* ---- Vue HEATMAP ---- */}
{viewMode === 'heatmap' && (
<>
{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`}
sub={formatDU(stats.totalVolume, currentUD)}
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
/>
<StatCard
label="Transactions"
value={stats.transactionCount.toLocaleString('fr-FR')}
sub={(() => {
const avg = stats.totalVolume / (stats.transactionCount || 1);
return `${avg.toFixed(2)} Ğ1 / tx · ${formatDU(avg, currentUD)} / tx`;
})()}
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
/>
{/* Couverture géo — transactionCount inclut le total réel de la frame */}
{source === 'live' && stats.transactionCount > 0 && (() => {
const pct = Math.round((stats.geoCount / stats.transactionCount) * 100);
return (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
<div className="flex justify-between items-center mb-1.5">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
<p className="text-[#6b7280] text-xs">{stats.geoCount} / {stats.transactionCount}</p>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
<div
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
</div>
);
})()}
</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 gap-2.5"
>
<span className="text-base shrink-0 mt-0.5">{MEDALS[i]}</span>
<div className="flex-1 min-w-0">
<p className="text-white text-xs font-medium leading-snug">{city.name}</p>
<div className="flex items-center justify-between mt-1">
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
{city.countryCode && (
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
{city.countryCode}
</span>
)}
{city.count} tx
</span>
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
{delta(city.volume, prevCityVolume, city.name)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
{/* ---- Vue FLUX ---- */}
{viewMode === 'flow' && (
<>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-16 animate-pulse" />
))}
</div>
) : flowStats ? (
<div className="space-y-3">
<StatCard
label="Volume des flux"
value={`${flowStats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
sub={formatDU(flowStats.totalVolume, currentUD)}
/>
<StatCard
label="Arcs géolocalisés"
value={flowStats.arcCount.toLocaleString('fr-FR')}
sub={flowStats.arcCount > 0
? `${(flowStats.totalVolume / flowStats.arcCount).toFixed(2)} Ğ1 / arc`
: undefined}
/>
{/* Top émetteurs */}
{flowStats.topEmitters.length > 0 && (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Top émetteurs</p>
{flowStats.topEmitters.map((c, i) => (
<div key={c.city} className="flex items-center gap-2">
<span className="text-sm shrink-0">{MEDALS[i]}</span>
<CityRow city={c.city} volume={c.volume} count={c.count} countryCode={c.countryCode} accent="text-[#ff8f00]" />
</div>
))}
</div>
)}
{/* Top récepteurs */}
{flowStats.topReceivers.length > 0 && (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Top récepteurs</p>
{flowStats.topReceivers.map((c, i) => (
<div key={c.city} className="flex items-center gap-2">
<span className="text-sm shrink-0">{MEDALS[i]}</span>
<CityRow city={c.city} volume={c.volume} count={c.count} countryCode={c.countryCode} accent="text-[#00acc1]" />
</div>
))}
</div>
)}
{/* Balance nette */}
{flowStats.netBalance.length > 0 && (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
<div className="flex items-center justify-between">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
<p className="text-[10px] text-[#4b5563] flex items-center gap-1.5">
<span style={{ color: '#ff6d00' }}></span>émetteur
<span style={{ color: '#00c853' }}></span>récepteur
</p>
</div>
{flowStats.netBalance.map((c) => (
<div key={c.city} className="flex items-center justify-between">
<span className="text-white text-xs truncate">{c.city}</span>
<span className={`text-xs font-mono shrink-0 ml-2 ${c.net >= 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}>
{c.net >= 0 ? '+' : ''}{Math.round(c.net).toLocaleString('fr-FR')} Ğ1
</span>
</div>
))}
</div>
)}
{/* Ville focus */}
{focusCity && (
<div className="bg-[#0f1016] border border-[#d4a843]/30 rounded-xl p-3">
<p className="text-[#4b5563] text-xs uppercase tracking-widest mb-1">Ville sélectionnée</p>
<p className="text-[#d4a843] text-sm font-medium">{focusCity}</p>
<p className="text-[#4b5563] text-xs mt-0.5">
<span className="text-[#ff8f00]"></span> sortants &nbsp;
<span className="text-[#00acc1]"></span> entrants
</p>
</div>
)}
</div>
) : (
<p className="text-[#4b5563] text-xs">Aucun arc à afficher.</p>
)}
</>
)}
{/* Nature des échanges */}
{!loading && stats && stats.categoryBreakdown.length > 0 && stats.commentedCount > 0 && (
<div className="space-y-2 border-t border-[#1e1f2a] pt-3">
<div className="flex items-center justify-between">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nature des échanges</p>
<p className="text-[#2e2f3a] text-[10px] font-mono">{stats.commentedCount} commentés</p>
</div>
<div className="space-y-1">
{stats.categoryBreakdown
.filter((c) => c.category !== 'migration' && c.category !== 'ticket')
.slice(0, 7)
.map((c) => {
const pct = Math.round((c.count / stats.commentedCount) * 100);
const isOpen = openCategory === c.category;
const detail = transactions.filter((t) => t.category === c.category && t.comment);
return (
<div key={c.category}>
<button
onClick={() => setOpenCategory(isOpen ? null : c.category)}
className="w-full text-left group"
>
<div className="flex items-center justify-between mb-0.5">
<span className={`text-[11px] transition-colors ${isOpen ? 'text-white' : 'text-[#9ca3af] group-hover:text-white'}`}>
{isOpen ? '▾' : '▸'} {CATEGORY_LABELS[c.category]}
</span>
<span className="text-[#4b5563] text-[10px] font-mono">{c.count} · {pct}%</span>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1">
<div
className="h-1 rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: CATEGORY_COLORS[c.category] }}
/>
</div>
</button>
{isOpen && (
<div className="mt-1.5 mb-1 bg-[#0a0b0f] border border-[#1e1f2a] rounded-lg overflow-hidden">
{detail.length === 0 ? (
<p className="text-[#4b5563] text-[10px] px-3 py-2">Aucun commentaire disponible.</p>
) : (
<div className="max-h-48 overflow-y-auto divide-y divide-[#1e1f2a]">
{detail.slice(0, 30).map((t) => (
<div key={t.id} className="px-3 py-1.5 flex items-start justify-between gap-2">
<p className="text-[#9ca3af] text-[10px] italic leading-snug flex-1 min-w-0 truncate">
"{t.comment}"
</p>
<span className="text-[#d4a843] text-[10px] font-mono shrink-0">
{t.amount.toLocaleString('fr-FR', { maximumFractionDigits: 1 })} Ğ1
</span>
</div>
))}
{detail.length > 30 && (
<p className="text-[#4b5563] text-[10px] px-3 py-1.5 text-center">
+{detail.length - 30} autres
</p>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Footer */}
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
<p className="text-[#2e2f3a] text-xs text-center">
{source === 'live' ? 'Ğ1v2 · Subsquid + Cesium+' : 'Données simulées · mock'}
</p>
<a
href="https://git.open.us.org/syoul/g1flux"
target="_blank"
rel="noopener noreferrer"
className="block text-[#d4a843] hover:text-[#e8c060] text-xs text-center transition-colors"
>
git.open.us.org/syoul/g1flux
</a>
<p className="text-[#d4a843] text-xs text-center">
Logiciel libre sous licence AGPLv3
</p>
</div>
</aside>
);
}