feat: taux de géoloc réel par frame + DU + périodeSélecteur + autoplay anim
ci/woodpecker/push/woodpecker Pipeline was successful

- Affiche l'équivalent en DU pour le volume total et la moyenne par tx
- Taux de géolocalisation réel par frame d'animation (via allTimestamps)
- Sélecteur de période personnalisée inline à côté des boutons 24h/7j/30j
- Clic sur Animer lance la lecture automatique à vitesse ×1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-23 23:41:59 +01:00
parent 42286a8c0d
commit b9bcfa8518
4 changed files with 33 additions and 25 deletions
+6 -3
View File
@@ -18,8 +18,9 @@ export default function App() {
const [lastUpdate, setLastUpdate] = useState<Date | null>(null); const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [source, setSource] = useState<'live' | 'mock'>('mock'); const [source, setSource] = useState<'live' | 'mock'>('mock');
const [currentUD, setCurrentUD] = useState<number>(11.78); const [currentUD, setCurrentUD] = useState<number>(11.78);
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
const animation = useAnimation(transactions, periodDays); const animation = useAnimation(transactions, periodDays, allTimestamps);
const handlePeriodChange = (days: number) => { const handlePeriodChange = (days: number) => {
animation.deactivate(); animation.deactivate();
@@ -33,12 +34,13 @@ export default function App() {
if (showLoading) setLoading(true); if (showLoading) setLoading(true);
else setRefreshing(true); else setRefreshing(true);
fetchData(periodDays) fetchData(periodDays)
.then(({ transactions, stats, source, currentUD }) => { .then(({ transactions, stats, source, currentUD, allTimestamps }) => {
if (!cancelled) { if (!cancelled) {
setTransactions(transactions); setTransactions(transactions);
setStats(stats); setStats(stats);
setSource(source); setSource(source);
setCurrentUD(currentUD); setCurrentUD(currentUD);
setAllTimestamps(allTimestamps);
setLastUpdate(new Date()); setLastUpdate(new Date());
} }
}) })
@@ -59,6 +61,8 @@ export default function App() {
? { ? {
...computeStats(animation.visibleTransactions), ...computeStats(animation.visibleTransactions),
geoCount: animation.visibleTransactions.length, geoCount: animation.visibleTransactions.length,
// frameTotalCount = total réel (géo + non-géo) dans cette frame
transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length,
} }
: stats; : stats;
@@ -72,7 +76,6 @@ export default function App() {
source={source} source={source}
currentUD={currentUD} currentUD={currentUD}
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined} animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
globalGeoStats={animation.active && stats ? { geoCount: stats.geoCount, transactionCount: stats.transactionCount } : undefined}
/> />
{/* Map area */} {/* Map area */}
+6 -14
View File
@@ -8,8 +8,6 @@ interface StatsPanelProps {
source: 'live' | 'mock'; source: 'live' | 'mock';
currentUD: number; currentUD: number;
animationLabel?: string; animationLabel?: string;
// Stats de géoloc de la période complète (indépendantes de la frame courante)
globalGeoStats?: { geoCount: number; transactionCount: number };
} }
const MEDALS = ['🥇', '🥈', '🥉']; const MEDALS = ['🥇', '🥈', '🥉'];
@@ -35,7 +33,7 @@ function formatDU(g1: number, ud: number): string {
return `${Math.round(du).toLocaleString('fr-FR')} DU`; return `${Math.round(du).toLocaleString('fr-FR')} DU`;
} }
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, globalGeoStats }: StatsPanelProps) { export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null); const prevStats = useRef<PeriodStats | null>(null);
@@ -108,20 +106,14 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
})()} })()}
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null} delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
/> />
{/* Couverture géo — toujours basée sur la période complète (pas la frame) */} {/* Couverture géo — transactionCount inclut le total réel de la frame */}
{source === 'live' && (() => { {source === 'live' && stats.transactionCount > 0 && (() => {
const geo = globalGeoStats ?? { geoCount: stats.geoCount, transactionCount: stats.transactionCount }; const pct = Math.round((stats.geoCount / stats.transactionCount) * 100);
const pct = geo.transactionCount > 0
? Math.round((geo.geoCount / geo.transactionCount) * 100)
: null;
if (pct === null) return null;
return ( return (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3"> <div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
<div className="flex justify-between items-center mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<p className="text-[#4b5563] text-xs uppercase tracking-widest"> <p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
Géolocalisées{animationLabel ? <span className="normal-case ml-1 opacity-60">(période)</span> : ''} <p className="text-[#6b7280] text-xs">{stats.geoCount} / {stats.transactionCount}</p>
</p>
<p className="text-[#6b7280] text-xs">{geo.geoCount} / {geo.transactionCount}</p>
</div> </div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5"> <div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
<div <div
+10 -1
View File
@@ -56,7 +56,7 @@ function buildFrames(periodDays: number): TimeFrame[] {
return frames; return frames;
} }
export function useAnimation(transactions: Transaction[], periodDays: number) { export function useAnimation(transactions: Transaction[], periodDays: number, allTimestamps: number[] = []) {
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
@@ -95,6 +95,14 @@ export function useAnimation(transactions: Transaction[], periodDays: number) {
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to); return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
}, [active, transactions, frames, currentIndex]); }, [active, transactions, frames, currentIndex]);
// Nombre total de transfers (géo + non-géo) dans la frame courante
const frameTotalCount = useMemo(() => {
if (!active || frames.length === 0 || allTimestamps.length === 0) return null;
const frame = frames[currentIndex];
if (!frame) return null;
return allTimestamps.filter((ts) => ts >= frame.from && ts < frame.to).length;
}, [active, allTimestamps, frames, currentIndex]);
return { return {
active, active,
activate: () => { setActive(true); setSpeed(1); setPlaying(true); }, activate: () => { setActive(true); setSpeed(1); setPlaying(true); },
@@ -109,5 +117,6 @@ export function useAnimation(transactions: Transaction[], periodDays: number) {
frames, frames,
currentFrame: frames[currentIndex] ?? null, currentFrame: frames[currentIndex] ?? null,
visibleTransactions, visibleTransactions,
frameTotalCount,
}; };
} }
+7 -3
View File
@@ -46,11 +46,12 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
geolocated: Transaction[]; geolocated: Transaction[];
totalCount: number; totalCount: number;
totalVolume: number; totalVolume: number;
allTimestamps: number[];
}> { }> {
// ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000 // ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
const limit = Math.max(2000, Math.ceil(periodDays * 600)); const limit = Math.max(2000, Math.ceil(periodDays * 600));
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit); const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit);
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 }; if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
@@ -99,7 +100,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
}); });
} }
return { geolocated, totalCount, totalVolume }; return { geolocated, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -117,6 +118,7 @@ export interface DataResult {
stats: PeriodStats; stats: PeriodStats;
source: 'live' | 'mock'; source: 'live' | 'mock';
currentUD: number; // valeur du DU courant en Ğ1 currentUD: number; // valeur du DU courant en Ğ1
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
} }
export async function fetchData(periodDays: number): Promise<DataResult> { export async function fetchData(periodDays: number): Promise<DataResult> {
@@ -129,10 +131,11 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
stats: { ...base, geoCount: transactions.length }, stats: { ...base, geoCount: transactions.length },
source: 'mock', source: 'mock',
currentUD: 11.78, currentUD: 11.78,
allTimestamps: transactions.map((t) => t.timestamp),
}; };
} }
const [{ geolocated, totalCount, totalVolume }, currentUD] = await Promise.all([ const [{ geolocated, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
fetchLiveTransactions(periodDays), fetchLiveTransactions(periodDays),
getCurrentUD(), getCurrentUD(),
]); ]);
@@ -148,5 +151,6 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
}, },
source: 'live', source: 'live',
currentUD, currentUD,
allTimestamps,
}; };
} }