25 Commits

Author SHA1 Message Date
syoul 7ee3b09f0f release: v1.0.0
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-23 23:47:47 +01:00
syoul b9bcfa8518 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>
2026-03-23 23:41:59 +01:00
syoul 42286a8c0d fix: barre de géoloc toujours visible, basée sur la période complète
ci/woodpecker/push/woodpecker Pipeline was successful
En mode animation, globalGeoStats passe les chiffres de la période entière
(depuis stats global) pour que la barre affiche le vrai taux Cesium+.
Le label indique "(période)" pour rappeler que ce n'est pas par frame.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:33:37 +01:00
syoul ee5e401185 fix: masquer la barre de géolocalisation en mode animation
ci/woodpecker/push/woodpecker Pipeline was successful
En mode animation, visibleTransactions ne contient que les tx géolocalisées
→ geoCount/transactionCount = 100% systématiquement, ce qui est trompeur.
La couverture Cesium+ est une propriété du pipeline global, pas d'une frame.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:30:05 +01:00
syoul f29625c6bc feat: clic Animer démarre la lecture automatiquement en vitesse ×1
ci/woodpecker/push/woodpecker Pipeline was successful
- activate() appelle maintenant setSpeed(1) + setPlaying(true) en plus de setActive(true)
- L'effet de reset ne stoppe playing que lors d'une désactivation (active=false),
  pas lors d'une activation, pour ne pas annuler le setPlaying(true) ci-dessus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:24:26 +01:00
syoul a2fdad46d4 feat: bouton Personnaliser pour période personnalisée (1–365 jours)
ci/woodpecker/push/woodpecker Pipeline was successful
Clic sur "Personnaliser" → champ inline focusé, pré-rempli si déjà custom.
Valider avec Entrée ou blur, annuler avec Échap. Plage 1–365 jours.
Le bouton affiche la valeur courante (ex. "14 jours") quand une période
custom est active, et reprend la surbrillance dorée comme les autres boutons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:17:10 +01:00
syoul 45080d83ac feat: afficher l'équivalent DU pour le volume total et la moyenne de transaction
ci/woodpecker/push/woodpecker Pipeline was successful
- SubsquidAdapter : fetchCurrentUD() interroge universalDividends (fallback 11.78 Ğ1)
- DataService : getCurrentUD() avec cache 1h, inclus dans DataResult
- StatsPanel : formatDU() + affichage "≈ X DU" sous le volume total
  et "≈ X Ğ1 / tx · ≈ Y DU / tx" sous le compteur de transactions
- DU actuel Ğ1v2 : 11.78 Ğ1 (bloc 225874)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:13:00 +01:00
syoul bea7cbe60f fix: crossfade via deux img overlays — canvas jamais modifié
ci/woodpecker/push/woodpecker Pipeline was successful
Problème racine : modifier l'opacité du canvas Leaflet (qui vit dans un
pane GPU-composité) via CSS causait des désynchronisations non-déterministes.

Nouvelle approche :
- Canvas : jamais touché (opacité Leaflet par défaut)
- Deux <img> overlays se croisent : prev (sortant) et next (entrant)
- Après draw(), on attend le RAF interne de Leaflet, puis on capture
  le canvas via toDataURL() dans le next img
- currentSrcRef garde l'src courante pour initialiser prev au prochain tour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:04:01 +01:00
syoul bc61527b4e fix: annuler les deux RAFs au cleanup pour éviter la double transition
ci/woodpecker/push/woodpecker Pipeline was successful
Le cleanup n'annulait que raf1. Si raf1 avait déjà tiré avant le cleanup React,
raf2 restait en queue et déclenchait une deuxième transition (l'aller-retour visible
à la fin de chaque frame). Fix : stocker raf2 dans la closure et l'annuler aussi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:43:12 +01:00
syoul ac2f5bc431 fix: vrai crossfade simultané — canvas fade-in + overlay fade-out en même temps
ci/woodpecker/push/woodpecker Pipeline was successful
Avant : overlay se dissout mais le canvas apparaît instantanément en dessous.
Maintenant : canvas part à opacity 0, les deux transitions démarrent en même temps
→ ancienne frame fade out pendant que la nouvelle fade in simultanément.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:25:26 +01:00
syoul 2debc3587a fix: crossfade simplifié — canvas toujours visible, seul l'overlay se dissout
ci/woodpecker/push/woodpecker Pipeline was successful
Le canvas reste toujours à opacity 1. Quand les transactions changent :
1. Capture le canvas dans l'overlay img (snap à opacity 1 sans transition)
2. Met à jour le canvas en dessous
3. Double rAF pour laisser Leaflet.heat redessiner
4. Dissout l'overlay de 1→0 en 500ms via CSS transition

Élimine le double-affichage et les conflits de transition canvas/overlay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:18:15 +01:00
syoul 8a31b60716 fix: éliminer le double affichage du crossfade
ci/woodpecker/push/woodpecker Pipeline was successful
Problème : void canvas.offsetWidth forçait un repaint avec canvas ET
overlay potentiellement visibles en même temps.

Fix : flusher uniquement l'overlay (void overlay.offsetWidth), puis
appliquer canvas=0 + overlay=1 dans le même batch de paint — Frame A
passe du canvas à l'overlay en un seul rendu sans doublon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:07:34 +01:00
syoul a9bf445747 fix: force reflow avant reset des transitions CSS du crossfade
ci/woodpecker/push/woodpecker Pipeline was successful
Sans forcer un reflow, le browser ignore transition:none et applique
encore l'ancienne transition — causant un bug visuel sur la 1ère frame.
void canvas.offsetWidth flush les styles en attente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:05:37 +01:00
syoul d7fef466f3 fix: vrai crossfade simultané — canvas masqué puis fade in+out en parallèle
ci/woodpecker/push/woodpecker Pipeline was successful
Canvas caché (opacity 0) avant update → overlay (frame A) fade out
et canvas (frame B) fade in simultanément sur 500ms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:00:42 +01:00
syoul 14d218e4ff feat: vrai fondu enchaîné par overlay image sur le heatmap
ci/woodpecker/push/woodpecker Pipeline was successful
Principe : capture du canvas heatmap actuel dans une <img> superposée
(opacity 1), mise à jour immédiate du heatmap en dessous, puis
dissolution de l'overlay (opacity 0 en 500ms). Les deux frames
coexistent pendant la transition → vrai dissolve sans clignotement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:55:51 +01:00
syoul d50b30666b feat: améliore le fondu enchaîné et recalibre les vitesses
ci/woodpecker/push/woodpecker Pipeline was successful
- Fondu : dip à 0.15 (au lieu de 0) pour un effet dissolve plutôt
  qu'un blink; ease-out 150ms / ease-in 200ms
- Délais : 1500ms base (×1=1.5s, ×2=750ms, ×4=375ms)
- Vitesse par défaut : ×2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:52:44 +01:00
syoul 30057a07fb feat: fondu entre les frames de l'animation heatmap
ci/woodpecker/push/woodpecker Pipeline was successful
Fade out 250ms → mise à jour des données → fade in 250ms sur le canvas
Leaflet.heat. Aucun état React supplémentaire — manipulation directe
du canvas interne via _canvas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:47:10 +01:00
syoul 40c09e2e4b feat: ajoute le jour de la semaine dans les labels demi-semaines
ci/woodpecker/push/woodpecker Pipeline was successful
Ex: "lun. 21 févr. – jeu. 24 févr."

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:43:13 +01:00
syoul 0aea929b48 feat: animation 30 jours en demi-semaines (3.5j, ~9 frames)
ci/woodpecker/push/woodpecker Pipeline was successful
Remplace les frames hebdomadaires (5 frames) par des demi-semaines
(3.5 jours, ~9-10 frames) pour une animation plus fluide sur 30 jours.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:40:48 +01:00
syoul d4cc4fbd3a fix: accepter city/title null dans le schéma Zod Cesium+
ci/woodpecker/push/woodpecker Pipeline was successful
Un profil Cesium+ (clé 2QsNk...) a city:null. La contrainte
.string().optional() accepte undefined mais pas null → ZodError
silencieux dans resolveGeoByKeys → geoMap vide → 0 transactions
affichées en mode 30 jours.

Correction : .string().nullable().optional() pour title et city.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:36:47 +01:00
syoul bf2dbd6d35 fix: accepter blockNumber négatif dans le schéma Zod Subsquid
ci/woodpecker/push/woodpecker Pipeline was successful
Les transferts Ğ1v1 migrés (avant le 7 mars 2026) ont des blockNumber
négatifs dans l'indexeur Subsquid. La contrainte .positive() provoquait
un ZodError silencieux qui abandonnait le fetch 30 jours et conservait
les données 7 jours en mémoire — d'où les frames vides en animation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:30:00 +01:00
syoul 2fce063703 fix: limite dynamique pour fetchTransfers selon la période
ci/woodpecker/push/woodpecker Pipeline was successful
Hardcoder limit=2000 ne couvrait que ~5 jours (400 tx/jour × 5 = 2000).
La limite est maintenant calculée : max(2000, periodDays × 600).
- 1j  → 2000  (inchangé)
- 7j  → 4200
- 30j → 18000  (couvre ~45 jours de marge)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:46:49 +01:00
syoul 3aa3933b4c fix: corrige la dérive temporelle du pool mock
ci/woodpecker/push/woodpecker Pipeline was successful
Les timestamps du pool étaient figés au moment du chargement du module.
On calcule le drift entre l'heure de génération et l'heure courante,
et on le réapplique à chaque appel à getTransactionsForPeriod.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:41:52 +01:00
syoul 2ed51243d2 chore: ajoute docs-bugs/ au .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:39:15 +01:00
syoul 7975abc619 feat: animation temporelle des flux Ğ1
ci/woodpecker/push/woodpecker Pipeline was successful
Nouveau mode animation accessible via "▶ Animer" dans le sélecteur de période.
- useAnimation : hook gérant frames, lecture, vitesse, filtrage client
- AnimationPlayer : barre de contrôle (play/pause, slider, ×1/×2/×4)
- Granularité auto : 24 frames/h (24h), 7 frames/jour (7j), ~4 frames/semaine (30j)
- Stats et heatmap mis à jour sur la fenêtre courante, zéro requête réseau supplémentaire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:29:25 +01:00
11 changed files with 556 additions and 61 deletions
+1
View File
@@ -30,3 +30,4 @@ dist-ssr
/docs-plan/
/docs-syoul/
/docs-bugs/
+57 -6
View File
@@ -2,9 +2,12 @@ import { useState, useEffect } from 'react';
import { StatsPanel } from './components/StatsPanel';
import { PeriodSelector } from './components/PeriodSelector';
import { HeatMap } from './components/HeatMap';
import { AnimationPlayer } from './components/AnimationPlayer';
import { fetchData } from './services/DataService';
import type { PeriodStats } from './services/DataService';
import type { Transaction } from './data/mockData';
import { computeStats } from './data/mockData';
import { useAnimation } from './hooks/useAnimation';
export default function App() {
const [periodDays, setPeriodDays] = useState(7);
@@ -14,6 +17,15 @@ export default function App() {
const [refreshing, setRefreshing] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [source, setSource] = useState<'live' | 'mock'>('mock');
const [currentUD, setCurrentUD] = useState<number>(11.78);
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
const animation = useAnimation(transactions, periodDays, allTimestamps);
const handlePeriodChange = (days: number) => {
animation.deactivate();
setPeriodDays(days);
};
useEffect(() => {
let cancelled = false;
@@ -22,11 +34,13 @@ export default function App() {
if (showLoading) setLoading(true);
else setRefreshing(true);
fetchData(periodDays)
.then(({ transactions, stats, source }) => {
.then(({ transactions, stats, source, currentUD, allTimestamps }) => {
if (!cancelled) {
setTransactions(transactions);
setStats(stats);
setSource(source);
setCurrentUD(currentUD);
setAllTimestamps(allTimestamps);
setLastUpdate(new Date());
}
})
@@ -42,22 +56,44 @@ export default function App() {
return () => { cancelled = true; clearInterval(interval); };
}, [periodDays]);
// Stats calculées sur la fenêtre courante en mode animation
const visibleStats: PeriodStats | null = animation.active
? {
...computeStats(animation.visibleTransactions),
geoCount: animation.visibleTransactions.length,
// frameTotalCount = total réel (géo + non-géo) dans cette frame
transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length,
}
: stats;
return (
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
{/* Side panel */}
<StatsPanel stats={stats} loading={loading} periodDays={periodDays} source={source} />
<StatsPanel
stats={visibleStats}
loading={loading}
periodDays={periodDays}
source={source}
currentUD={currentUD}
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
/>
{/* Map area */}
<div className="relative flex-1 min-w-0">
<HeatMap transactions={transactions} />
<HeatMap transactions={animation.visibleTransactions} />
{/* Period selector — floating over map */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
<PeriodSelector value={periodDays} onChange={setPeriodDays} />
<PeriodSelector
value={periodDays}
onChange={handlePeriodChange}
animationActive={animation.active}
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
/>
</div>
{/* Transaction count + source badge */}
{!loading && (
{/* Transaction count + source badge (masqués en mode animation) */}
{!loading && !animation.active && (
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
@@ -74,6 +110,21 @@ export default function App() {
</div>
)}
{/* Animation player */}
{animation.active && (
<AnimationPlayer
frames={animation.frames}
currentIndex={animation.currentIndex}
playing={animation.playing}
speed={animation.speed}
onSeek={animation.seek}
onPlay={animation.play}
onPause={animation.pause}
onSpeedChange={animation.setSpeed}
onClose={animation.deactivate}
/>
)}
{/* Loading overlay */}
{loading && (
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
+109
View File
@@ -0,0 +1,109 @@
import type { TimeFrame } from '../hooks/useAnimation';
interface AnimationPlayerProps {
frames: TimeFrame[];
currentIndex: number;
playing: boolean;
speed: 1 | 2 | 4;
onSeek: (i: number) => void;
onPlay: () => void;
onPause: () => void;
onSpeedChange: (s: 1 | 2 | 4) => void;
onClose: () => void;
}
export function AnimationPlayer({
frames,
currentIndex,
playing,
speed,
onSeek,
onPlay,
onPause,
onSpeedChange,
onClose,
}: AnimationPlayerProps) {
const frame = frames[currentIndex];
return (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
{/* Frame label + position */}
<div className="flex items-center justify-between">
<span className="text-[#d4a843] text-sm font-medium">
{frame?.label ?? '—'}
</span>
<span className="text-[#4b5563] text-xs tabular-nums">
{currentIndex + 1} / {frames.length}
</span>
</div>
{/* Slider */}
<input
type="range"
min={0}
max={frames.length - 1}
value={currentIndex}
onChange={(e) => onSeek(Number(e.target.value))}
className="w-full h-1 accent-[#d4a843] cursor-pointer"
/>
{/* Controls row */}
<div className="flex items-center justify-between">
{/* Playback buttons */}
<div className="flex items-center gap-1">
<button
onClick={() => onSeek(Math.max(0, currentIndex - 1))}
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
title="Frame précédente"
>
</button>
<button
onClick={playing ? onPause : onPlay}
className="px-4 py-1.5 bg-[#d4a843] text-[#0a0b0f] rounded-lg font-bold text-sm hover:bg-[#e8c060] transition-colors min-w-[52px] text-center shadow-[0_0_10px_rgba(212,168,67,0.3)]"
>
{playing ? '⏸' : '▶'}
</button>
<button
onClick={() => onSeek(Math.min(frames.length - 1, currentIndex + 1))}
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
title="Frame suivante"
>
</button>
</div>
{/* Speed selector */}
<div className="flex items-center gap-1">
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
{([1, 2, 4] as const).map((s) => (
<button
key={s}
onClick={() => onSpeedChange(s)}
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
speed === s
? 'bg-[#d4a843] text-[#0a0b0f]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}`}
>
×{s}
</button>
))}
</div>
{/* Close */}
<button
onClick={onClose}
className="text-[#4b5563] hover:text-white transition-colors px-2 py-1 text-sm ml-2"
title="Quitter l'animation"
>
</button>
</div>
</div>
</div>
);
}
+87 -18
View File
@@ -33,6 +33,12 @@ export function HeatMap({ transactions }: HeatMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const heatRef = useRef<L.HeatLayer | null>(null);
// Two img overlays that cross-fade between each other.
// The canvas opacity is NEVER touched — it stays at leaflet's default.
const prevRef = useRef<HTMLImageElement | null>(null);
const nextRef = useRef<HTMLImageElement | null>(null);
// Src of the currently visible frame (so prev can be initialised correctly)
const currentSrcRef = useRef<string>('');
// Initialize map once
useEffect(() => {
@@ -64,32 +70,95 @@ export function HeatMap({ transactions }: HeatMapProps) {
};
}, []);
// Update heatmap data when transactions change
// Crossfade: two img overlays swap roles each frame.
// Canvas is never hidden — we only read its pixel data via toDataURL().
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 canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas;
const prev = prevRef.current;
const next = nextRef.current;
const points: L.HeatLatLngTuple[] = transactions.map((tx) => [
tx.lat,
tx.lng,
Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1),
]);
const draw = () => {
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),
]);
try {
heatRef.current?.setLatLngs(points);
} catch {
// map was torn down (React StrictMode double-invoke), ignore
}
};
// 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
if (!canvas || !prev || !next) {
draw();
return;
}
// --- Phase 1 (synchronous): set start state ---
// prev shows the current frame (or nothing on first run)
prev.src = currentSrcRef.current;
prev.style.transition = 'none';
prev.style.opacity = currentSrcRef.current ? '1' : '0';
// next is hidden and will receive the incoming frame
next.style.transition = 'none';
next.style.opacity = '0';
void prev.offsetWidth; // flush CSS so transitions start cleanly
// Ask leaflet to draw new data (schedules an internal RAF)
draw();
// --- Phase 2 (after leaflet redraws): capture new frame, start crossfade ---
// leaflet.heat schedules its own RAF inside draw() above.
// Our raf1 is queued *after* leaflet's RAF, so when raf1 fires,
// leaflet has already redrawn the canvas.
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
let src: string;
try {
src = canvas.toDataURL();
} catch {
return; // map torn down
}
currentSrcRef.current = src;
next.src = src;
void next.offsetWidth; // ensure img is decoded before transition
const DUR = '0.55s ease-in-out';
prev.style.transition = `opacity ${DUR}`;
prev.style.opacity = '0';
next.style.transition = `opacity ${DUR}`;
next.style.opacity = '1';
});
});
return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); };
}, [transactions]);
return (
<div
ref={containerRef}
className="w-full h-full"
style={{ minHeight: 0 }}
/>
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
<div ref={containerRef} className="absolute inset-0" />
{/* prev: outgoing frame */}
<img
ref={prevRef}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0, zIndex: 500 }}
/>
{/* next: incoming frame — sits on top of prev during crossfade */}
<img
ref={nextRef}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0, zIndex: 501 }}
/>
</div>
);
}
+84 -4
View File
@@ -1,6 +1,10 @@
import { useState, useRef, useEffect } from 'react';
interface PeriodSelectorProps {
value: number;
onChange: (days: number) => void;
animationActive: boolean;
onAnimate: () => void;
}
const PERIODS = [
@@ -9,16 +13,40 @@ const PERIODS = [
{ label: '30 jours', days: 30 },
];
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
const PRESET_DAYS = new Set([1, 7, 30]);
export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) {
const [customOpen, setCustomOpen] = useState(false);
const [inputVal, setInputVal] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
// Ouvre le champ custom avec la valeur courante pré-remplie
const openCustom = () => {
setInputVal(PRESET_DAYS.has(value) ? '' : String(value));
setCustomOpen(true);
};
useEffect(() => {
if (customOpen) inputRef.current?.focus();
}, [customOpen]);
const commit = () => {
const n = parseInt(inputVal, 10);
if (n >= 1 && n <= 365) onChange(n);
setCustomOpen(false);
};
const isCustomActive = !PRESET_DAYS.has(value);
return (
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1">
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
{PERIODS.map(({ label, days }) => (
<button
key={days}
onClick={() => onChange(days)}
onClick={() => { onChange(days); setCustomOpen(false); }}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${value === days
${value === days && !customOpen
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
@@ -27,6 +55,58 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
{label}
</button>
))}
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
{/* Bouton Personnaliser + champ inline */}
{customOpen ? (
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="number"
min={1}
max={365}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setCustomOpen(false);
}}
onBlur={commit}
placeholder="jours"
className="w-16 px-2 py-1 text-sm bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] text-center focus:outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/>
<span className="text-[#6b7280] text-xs">j</span>
</div>
) : (
<button
onClick={openCustom}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${isCustomActive
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
`}
>
{isCustomActive ? `${value} jours` : 'Personnaliser'}
</button>
)}
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
<button
onClick={onAnimate}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${animationActive
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
`}
>
Animer
</button>
</div>
);
}
+37 -21
View File
@@ -6,6 +6,8 @@ interface StatsPanelProps {
loading: boolean;
periodDays: number;
source: 'live' | 'mock';
currentUD: number;
animationLabel?: string;
}
const MEDALS = ['🥇', '🥈', '🥉'];
@@ -24,7 +26,14 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string;
);
}
export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) {
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`;
}
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
@@ -46,9 +55,6 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
// Mémorise les stats après le rendu
if (stats && !loading) prevStats.current = stats;
const geoPct = stats && stats.transactionCount > 0
? Math.round((stats.geoCount / stats.transactionCount) * 100)
: null;
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">
@@ -70,7 +76,10 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
{/* Period label */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
Période : <span className="text-[#6b7280]">{periodLabel}</span>
{animationLabel
? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></>
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
}
</p>
{/* Stats */}
@@ -85,30 +94,37 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
<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={`${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`}
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 — uniquement en mode live */}
{source === 'live' && geoPct !== null && (
<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>
{/* 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 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: `${geoPct}%` }}
/>
</div>
<p className="text-[#4b5563] text-xs mt-1 text-right">{geoPct}% via Cesium+</p>
</div>
)}
);
})()}
</div>
) : null}
+5 -1
View File
@@ -85,11 +85,15 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
return transactions.sort((a, b) => b.timestamp - a.timestamp);
}
const POOL_GENERATED_AT = Date.now();
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
export function getTransactionsForPeriod(periodDays: number): Transaction[] {
const drift = Date.now() - POOL_GENERATED_AT;
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
return TRANSACTION_POOL.filter((tx) => tx.timestamp >= cutoff);
return TRANSACTION_POOL
.map((tx) => ({ ...tx, timestamp: tx.timestamp + drift }))
.filter((tx) => tx.timestamp >= cutoff);
}
export function computeStats(transactions: Transaction[]) {
+122
View File
@@ -0,0 +1,122 @@
import { useState, useMemo, useEffect } from 'react';
import type { Transaction } from '../data/mockData';
export interface TimeFrame {
label: string;
from: number; // Unix ms
to: number; // Unix ms
}
function buildFrames(periodDays: number): TimeFrame[] {
const now = Date.now();
const start = now - periodDays * 24 * 60 * 60 * 1000;
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
new Date(ms).toLocaleDateString('fr-FR', opts);
if (periodDays === 1) {
return Array.from({ length: 24 }, (_, i) => {
const from = start + i * 3_600_000;
const to = from + 3_600_000;
const h = new Date(from).getHours();
return {
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h ${h + 1}h`,
from,
to,
};
});
}
if (periodDays === 7) {
return Array.from({ length: 7 }, (_, i) => {
const from = start + i * 86_400_000;
const to = from + 86_400_000;
return {
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
from,
to,
};
});
}
// 30 days → half-week frames (3.5 days ≈ 910 frames)
const HALF_WEEK = 3.5 * 86_400_000;
const frames: TimeFrame[] = [];
let cursor = start;
while (cursor < now) {
const from = cursor;
const to = Math.min(cursor + HALF_WEEK, now);
frames.push({
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
from,
to,
});
cursor = to;
}
return frames;
}
export function useAnimation(transactions: Transaction[], periodDays: number, allTimestamps: number[] = []) {
const [active, setActive] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<1 | 2 | 4>(2);
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
// Reset cursor when period or activation changes.
// Stop playback only on deactivation — not on activation, so activate() can
// start playing immediately without being overridden by this effect.
useEffect(() => {
setCurrentIndex(0);
if (!active) setPlaying(false);
}, [periodDays, active]);
// Auto-advance: one step every (2000 / speed) ms
useEffect(() => {
if (!playing || !active) return;
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
const t = setTimeout(() => {
setCurrentIndex((i) => {
if (i >= frames.length - 1) {
setPlaying(false);
return i;
}
return i + 1;
});
}, delay);
return () => clearTimeout(t);
}, [playing, active, currentIndex, speed, frames.length]);
const visibleTransactions = useMemo(() => {
if (!active || frames.length === 0) return transactions;
const frame = frames[currentIndex];
if (!frame) return transactions;
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
}, [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 {
active,
activate: () => { setActive(true); setSpeed(1); setPlaying(true); },
deactivate: () => { setActive(false); },
playing,
play: () => setPlaying(true),
pause: () => setPlaying(false),
currentIndex,
seek: (i: number) => { setCurrentIndex(i); setPlaying(false); },
speed,
setSpeed,
frames,
currentFrame: frames[currentIndex] ?? null,
visibleTransactions,
frameTotalCount,
};
}
+30 -8
View File
@@ -12,7 +12,7 @@
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/
import { fetchTransfers, buildIdentityKeyMap } from './adapters/SubsquidAdapter';
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys } from './adapters/CesiumAdapter';
import {
getTransactionsForPeriod,
@@ -22,6 +22,16 @@ import {
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
// Cache du DU courant, valide 1 heure (le DU change tous les ~6 mois)
let udCache: { value: number; expiresAt: number } | null = null;
async function getCurrentUD(): Promise<number> {
if (udCache && Date.now() < udCache.expiresAt) return udCache.value;
const value = await fetchCurrentUD();
udCache = { value, expiresAt: Date.now() + 60 * 60 * 1000 };
return value;
}
// Cache de la carte identité SS58→DuniterKey, valide 10 minutes
let keyMapCache: { map: Map<string, string>; expiresAt: number } | null = null;
@@ -36,9 +46,12 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
geolocated: Transaction[];
totalCount: number;
totalVolume: number;
allTimestamps: number[];
}> {
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays);
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 };
// ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
const limit = Math.max(2000, Math.ceil(periodDays * 600));
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit);
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
@@ -87,7 +100,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
});
}
return { geolocated, totalCount, totalVolume };
return { geolocated, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
}
// ---------------------------------------------------------------------------
@@ -101,9 +114,11 @@ export interface PeriodStats {
}
export interface DataResult {
transactions: Transaction[]; // uniquement géolocalisées → heatmap
stats: PeriodStats;
source: 'live' | 'mock';
transactions: Transaction[]; // uniquement géolocalisées → heatmap
stats: PeriodStats;
source: 'live' | 'mock';
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> {
@@ -115,10 +130,15 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
transactions,
stats: { ...base, geoCount: transactions.length },
source: 'mock',
currentUD: 11.78,
allTimestamps: transactions.map((t) => t.timestamp),
};
}
const { geolocated, totalCount, totalVolume } = await fetchLiveTransactions(periodDays);
const [{ geolocated, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
fetchLiveTransactions(periodDays),
getCurrentUD(),
]);
const base = computeStats(geolocated);
return {
@@ -130,5 +150,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
topCities: base.topCities,
},
source: 'live',
currentUD,
allTimestamps,
};
}
+2 -2
View File
@@ -83,8 +83,8 @@ function countryCodeFromCity(city: string): string {
const HitSchema = z.object({
_id: z.string(),
_source: z.object({
title: z.string().optional(),
city: z.string().optional(),
title: z.string().nullable().optional(),
city: z.string().nullable().optional(),
geoPoint: z.unknown().optional(),
}),
});
+22 -1
View File
@@ -20,7 +20,7 @@ export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
// ---------------------------------------------------------------------------
const SubsquidTransferNodeSchema = z.object({
id: z.string(),
blockNumber: z.number().int().positive(),
blockNumber: z.number().int(), // peut être négatif pour les blocs Ğ1v1 migrés
timestamp: z.string(), // ISO 8601 ex: "2026-03-22T14:53:36+00:00"
amount: z.string(), // BigInt en string, en centimes Ğ1
fromId: z.string().nullable(),
@@ -153,6 +153,27 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
return result;
}
/** Retourne la valeur du DU courant en Ğ1 (ex : 11.78). Fallback hardcodé si indisponible. */
export async function fetchCurrentUD(): Promise<number> {
const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue
try {
const response = await fetch(SUBSQUID_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `{ universalDividends(orderBy: BLOCK_NUMBER_DESC, first: 1) { nodes { amount } } }`,
}),
});
if (!response.ok) return UD_FALLBACK;
const raw = await response.json();
const amountStr: string | undefined = raw?.data?.universalDividends?.nodes?.[0]?.amount;
if (!amountStr) return UD_FALLBACK;
return parseInt(amountStr, 10) / 100;
} catch {
return UD_FALLBACK;
}
}
export interface FetchTransfersResult {
transfers: RawTransfer[];
totalCount: number;