From 7975abc619174572dac67b6cd35bdd740d063b0d Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 20:29:25 +0100 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20animation=20temporelle=20des=20fl?= =?UTF-8?q?ux=20=C4=9E1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 54 ++++++++++++-- src/components/AnimationPlayer.tsx | 109 ++++++++++++++++++++++++++++ src/components/PeriodSelector.tsx | 17 ++++- src/components/StatsPanel.tsx | 8 ++- src/hooks/useAnimation.ts | 112 +++++++++++++++++++++++++++++ 5 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 src/components/AnimationPlayer.tsx create mode 100644 src/hooks/useAnimation.ts diff --git a/src/App.tsx b/src/App.tsx index e7e4729..87db7d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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); @@ -15,6 +18,13 @@ export default function App() { const [lastUpdate, setLastUpdate] = useState(null); const [source, setSource] = useState<'live' | 'mock'>('mock'); + const animation = useAnimation(transactions, periodDays); + + const handlePeriodChange = (days: number) => { + animation.deactivate(); + setPeriodDays(days); + }; + useEffect(() => { let cancelled = false; @@ -42,22 +52,41 @@ 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, + } + : stats; + return (
{/* Side panel */} - + {/* Map area */}
- + {/* Period selector — floating over map */}
- + animation.active ? animation.deactivate() : animation.activate()} + />
- {/* Transaction count + source badge */} - {!loading && ( + {/* Transaction count + source badge (masqués en mode animation) */} + {!loading && !animation.active && (
{transactions.length} transactions affichées @@ -74,6 +103,21 @@ export default function App() {
)} + {/* Animation player */} + {animation.active && ( + + )} + {/* Loading overlay */} {loading && (
diff --git a/src/components/AnimationPlayer.tsx b/src/components/AnimationPlayer.tsx new file mode 100644 index 0000000..6e1817f --- /dev/null +++ b/src/components/AnimationPlayer.tsx @@ -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 ( +
+
+ + {/* Frame label + position */} +
+ + {frame?.label ?? '—'} + + + {currentIndex + 1} / {frames.length} + +
+ + {/* Slider */} + onSeek(Number(e.target.value))} + className="w-full h-1 accent-[#d4a843] cursor-pointer" + /> + + {/* Controls row */} +
+ + {/* Playback buttons */} +
+ + + +
+ + {/* Speed selector */} +
+ Vitesse + {([1, 2, 4] as const).map((s) => ( + + ))} +
+ + {/* Close */} + +
+
+
+ ); +} diff --git a/src/components/PeriodSelector.tsx b/src/components/PeriodSelector.tsx index 9ea0c88..f043cc0 100644 --- a/src/components/PeriodSelector.tsx +++ b/src/components/PeriodSelector.tsx @@ -1,6 +1,8 @@ interface PeriodSelectorProps { value: number; onChange: (days: number) => void; + animationActive: boolean; + onAnimate: () => void; } const PERIODS = [ @@ -9,7 +11,7 @@ const PERIODS = [ { label: '30 jours', days: 30 }, ]; -export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { +export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) { return (
{PERIODS.map(({ label, days }) => ( @@ -27,6 +29,19 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) { {label} ))} +
+
); } diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index a54a6ac..b5fb36c 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -6,6 +6,7 @@ interface StatsPanelProps { loading: boolean; periodDays: number; source: 'live' | 'mock'; + animationLabel?: string; } const MEDALS = ['🥇', '🥈', '🥉']; @@ -24,7 +25,7 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string; ); } -export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) { +export function StatsPanel({ stats, loading, periodDays, source, animationLabel }: StatsPanelProps) { const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const prevStats = useRef(null); @@ -70,7 +71,10 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro {/* Period label */}

- Période : {periodLabel} + {animationLabel + ? <> {animationLabel} + : <>Période : {periodLabel} + }

{/* Stats */} diff --git a/src/hooks/useAnimation.ts b/src/hooks/useAnimation.ts new file mode 100644 index 0000000..02ca2a6 --- /dev/null +++ b/src/hooks/useAnimation.ts @@ -0,0 +1,112 @@ +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 → weekly frames + const frames: TimeFrame[] = []; + let cursor = start; + let week = 1; + while (cursor < now) { + const from = cursor; + const to = Math.min(cursor + 7 * 86_400_000, now); + frames.push({ + label: `Semaine ${week} · ${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`, + from, + to, + }); + cursor = to; + week++; + } + return frames; +} + +export function useAnimation(transactions: Transaction[], periodDays: number) { + const [active, setActive] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const [playing, setPlaying] = useState(false); + const [speed, setSpeed] = useState<1 | 2 | 4>(1); + + const frames = useMemo(() => buildFrames(periodDays), [periodDays]); + + // Reset cursor and playback when period or activation changes + useEffect(() => { + setCurrentIndex(0); + setPlaying(false); + }, [periodDays, active]); + + // Auto-advance: one step every (2000 / speed) ms + useEffect(() => { + if (!playing || !active) return; + const delay = 2000 / speed; + 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]); + + return { + active, + activate: () => setActive(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, + }; +} From 2ed51243d28ecd89db4acd730c6064dff2709d88 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 20:39:15 +0100 Subject: [PATCH 02/24] chore: ajoute docs-bugs/ au .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0ecf508..1938f43 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr /docs-plan/ /docs-syoul/ +/docs-bugs/ From 3aa3933b4cfa38e366223d12c217d48a289ca652 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 20:41:52 +0100 Subject: [PATCH 03/24] =?UTF-8?q?fix:=20corrige=20la=20d=C3=A9rive=20tempo?= =?UTF-8?q?relle=20du=20pool=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/data/mockData.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/data/mockData.ts b/src/data/mockData.ts index fa019a9..bfb92f9 100644 --- a/src/data/mockData.ts +++ b/src/data/mockData.ts @@ -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[]) { From 2fce0637033be8581cc001d84d23992ec39ee332 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 20:46:49 +0100 Subject: [PATCH 04/24] =?UTF-8?q?fix:=20limite=20dynamique=20pour=20fetchT?= =?UTF-8?q?ransfers=20selon=20la=20p=C3=A9riode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/services/DataService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 56f62c7..c0c1dec 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -37,7 +37,9 @@ async function fetchLiveTransactions(periodDays: number): Promise<{ totalCount: number; totalVolume: number; }> { - const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays); + // ~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 }; const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); From bf2dbd6d359e58830fb43ed1558983cbc8dd4d70 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 21:30:00 +0100 Subject: [PATCH 05/24] =?UTF-8?q?fix:=20accepter=20blockNumber=20n=C3=A9ga?= =?UTF-8?q?tif=20dans=20le=20sch=C3=A9ma=20Zod=20Subsquid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/services/adapters/SubsquidAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/adapters/SubsquidAdapter.ts b/src/services/adapters/SubsquidAdapter.ts index 38bede1..2b76998 100644 --- a/src/services/adapters/SubsquidAdapter.ts +++ b/src/services/adapters/SubsquidAdapter.ts @@ -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(), From d4cc4fbd3ae3c60162a9c190de315f36bb107069 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 21:36:47 +0100 Subject: [PATCH 06/24] =?UTF-8?q?fix:=20accepter=20city/title=20null=20dan?= =?UTF-8?q?s=20le=20sch=C3=A9ma=20Zod=20Cesium+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/services/adapters/CesiumAdapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/adapters/CesiumAdapter.ts b/src/services/adapters/CesiumAdapter.ts index 2e67109..a2257d7 100644 --- a/src/services/adapters/CesiumAdapter.ts +++ b/src/services/adapters/CesiumAdapter.ts @@ -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(), }), }); From 0aea929b48a28c48a13584e1d6139438e97fe54f Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 21:40:48 +0100 Subject: [PATCH 07/24] feat: animation 30 jours en demi-semaines (3.5j, ~9 frames) 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 --- src/hooks/useAnimation.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/hooks/useAnimation.ts b/src/hooks/useAnimation.ts index 02ca2a6..d444363 100644 --- a/src/hooks/useAnimation.ts +++ b/src/hooks/useAnimation.ts @@ -39,20 +39,19 @@ function buildFrames(periodDays: number): TimeFrame[] { }); } - // 30 days → weekly frames + // 30 days → half-week frames (3.5 days ≈ 9–10 frames) + const HALF_WEEK = 3.5 * 86_400_000; const frames: TimeFrame[] = []; let cursor = start; - let week = 1; while (cursor < now) { const from = cursor; - const to = Math.min(cursor + 7 * 86_400_000, now); + const to = Math.min(cursor + HALF_WEEK, now); frames.push({ - label: `Semaine ${week} · ${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`, + label: `${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`, from, to, }); cursor = to; - week++; } return frames; } From 40c09e2e4b81ab073fb2c09a671de58b2c4a2a47 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 21:43:13 +0100 Subject: [PATCH 08/24] feat: ajoute le jour de la semaine dans les labels demi-semaines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ex: "lun. 21 févr. – jeu. 24 févr." Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useAnimation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useAnimation.ts b/src/hooks/useAnimation.ts index d444363..5cd30b7 100644 --- a/src/hooks/useAnimation.ts +++ b/src/hooks/useAnimation.ts @@ -47,7 +47,7 @@ function buildFrames(periodDays: number): TimeFrame[] { const from = cursor; const to = Math.min(cursor + HALF_WEEK, now); frames.push({ - label: `${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`, + label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} – ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`, from, to, }); From 30057a07fb86e41594242c3a9be2fbf66d63bb21 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 21:47:10 +0100 Subject: [PATCH 09/24] feat: fondu entre les frames de l'animation heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/HeatMap.tsx | 41 ++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 06070ae..f37a9c3 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -64,25 +64,40 @@ export function HeatMap({ transactions }: HeatMapProps) { }; }, []); - // Update heatmap data when transactions change + // Update heatmap data with fade transition 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 canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas; - const points: L.HeatLatLngTuple[] = transactions.map((tx) => [ - tx.lat, - tx.lng, - Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1), - ]); + const update = () => { + 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) { + update(); + return; } + + canvas.style.transition = 'opacity 0.25s ease-in-out'; + canvas.style.opacity = '0'; + + const t = setTimeout(() => { + update(); + canvas.style.opacity = '1'; + }, 250); + + return () => clearTimeout(t); }, [transactions]); return ( From d50b30666babb98c70d210224fe0bae9c40ca388 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 21:52:44 +0100 Subject: [PATCH 10/24] =?UTF-8?q?feat:=20am=C3=A9liore=20le=20fondu=20ench?= =?UTF-8?q?a=C3=AEn=C3=A9=20et=20recalibre=20les=20vitesses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/HeatMap.tsx | 7 ++++--- src/hooks/useAnimation.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index f37a9c3..bff36d4 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -89,13 +89,14 @@ export function HeatMap({ transactions }: HeatMapProps) { return; } - canvas.style.transition = 'opacity 0.25s ease-in-out'; - canvas.style.opacity = '0'; + canvas.style.transition = 'opacity 0.15s ease-out'; + canvas.style.opacity = '0.15'; const t = setTimeout(() => { update(); + canvas.style.transition = 'opacity 0.2s ease-in'; canvas.style.opacity = '1'; - }, 250); + }, 150); return () => clearTimeout(t); }, [transactions]); diff --git a/src/hooks/useAnimation.ts b/src/hooks/useAnimation.ts index 5cd30b7..0738b1a 100644 --- a/src/hooks/useAnimation.ts +++ b/src/hooks/useAnimation.ts @@ -60,7 +60,7 @@ export function useAnimation(transactions: Transaction[], periodDays: number) { const [active, setActive] = useState(false); const [currentIndex, setCurrentIndex] = useState(0); const [playing, setPlaying] = useState(false); - const [speed, setSpeed] = useState<1 | 2 | 4>(1); + const [speed, setSpeed] = useState<1 | 2 | 4>(2); const frames = useMemo(() => buildFrames(periodDays), [periodDays]); @@ -73,7 +73,7 @@ export function useAnimation(transactions: Transaction[], periodDays: number) { // Auto-advance: one step every (2000 / speed) ms useEffect(() => { if (!playing || !active) return; - const delay = 2000 / speed; + const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms const t = setTimeout(() => { setCurrentIndex((i) => { if (i >= frames.length - 1) { From 14d218e4ffcf08544b080b1f5c3eee6aa934c134 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 21:55:51 +0100 Subject: [PATCH 11/24] =?UTF-8?q?feat:=20vrai=20fondu=20encha=C3=AEn=C3=A9?= =?UTF-8?q?=20par=20overlay=20image=20sur=20le=20heatmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Principe : capture du canvas heatmap actuel dans une 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 --- src/components/HeatMap.tsx | 49 ++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index bff36d4..98db3e7 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -33,6 +33,7 @@ export function HeatMap({ transactions }: HeatMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const heatRef = useRef(null); + const overlayRef = useRef(null); // Initialize map once useEffect(() => { @@ -64,11 +65,12 @@ export function HeatMap({ transactions }: HeatMapProps) { }; }, []); - // Update heatmap data with fade transition when transactions change + // Crossfade: capture current heatmap → update underneath → fade out overlay useEffect(() => { if (!heatRef.current || !mapRef.current) return; const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas; + const overlay = overlayRef.current; const update = () => { const maxAmount = Math.max(...transactions.map((t) => t.amount), 1); @@ -84,28 +86,45 @@ export function HeatMap({ transactions }: HeatMapProps) { } }; - if (!canvas) { + if (!canvas || !overlay) { update(); return; } - canvas.style.transition = 'opacity 0.15s ease-out'; - canvas.style.opacity = '0.15'; - - const t = setTimeout(() => { + // Freeze current frame in the overlay + try { + overlay.src = canvas.toDataURL(); + } catch { + // canvas tainted (shouldn't happen with heatmap-only canvas) update(); - canvas.style.transition = 'opacity 0.2s ease-in'; - canvas.style.opacity = '1'; - }, 150); + return; + } + overlay.style.transition = 'none'; + overlay.style.opacity = '1'; - return () => clearTimeout(t); + // Update heatmap underneath immediately + update(); + + // Then dissolve the overlay away + const raf = requestAnimationFrame(() => { + requestAnimationFrame(() => { + overlay.style.transition = 'opacity 0.5s ease-in-out'; + overlay.style.opacity = '0'; + }); + }); + + return () => cancelAnimationFrame(raf); }, [transactions]); return ( -
+
+
+ +
); } From d7fef466f3c2bf0178f3e17bbc610a6fed1ef4e5 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 22:00:42 +0100 Subject: [PATCH 12/24] =?UTF-8?q?fix:=20vrai=20crossfade=20simultan=C3=A9?= =?UTF-8?q?=20=E2=80=94=20canvas=20masqu=C3=A9=20puis=20fade=20in+out=20en?= =?UTF-8?q?=20parall=C3=A8le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/HeatMap.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 98db3e7..9c67cd7 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -91,25 +91,33 @@ export function HeatMap({ transactions }: HeatMapProps) { return; } - // Freeze current frame in the overlay + // 1. Hide canvas instantly (no transition) + canvas.style.transition = 'none'; + canvas.style.opacity = '0'; + + // 2. Freeze current frame in the overlay try { overlay.src = canvas.toDataURL(); } catch { // canvas tainted (shouldn't happen with heatmap-only canvas) + canvas.style.opacity = '1'; update(); return; } overlay.style.transition = 'none'; overlay.style.opacity = '1'; - // Update heatmap underneath immediately + // 3. Update heatmap (invisible: canvas still at opacity 0) update(); - // Then dissolve the overlay away + // 4. Simultaneous crossfade: overlay fades out, canvas fades in const raf = requestAnimationFrame(() => { requestAnimationFrame(() => { - overlay.style.transition = 'opacity 0.5s ease-in-out'; + const DURATION = '0.5s ease-in-out'; + overlay.style.transition = `opacity ${DURATION}`; overlay.style.opacity = '0'; + canvas.style.transition = `opacity ${DURATION}`; + canvas.style.opacity = '1'; }); }); From a9bf44574722bc9ed905c90a529f9d990c76263c Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 22:05:37 +0100 Subject: [PATCH 13/24] fix: force reflow avant reset des transitions CSS du crossfade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/HeatMap.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 9c67cd7..ef12da7 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -91,20 +91,20 @@ export function HeatMap({ transactions }: HeatMapProps) { return; } - // 1. Hide canvas instantly (no transition) - canvas.style.transition = 'none'; - canvas.style.opacity = '0'; - // 2. Freeze current frame in the overlay try { overlay.src = canvas.toDataURL(); } catch { // canvas tainted (shouldn't happen with heatmap-only canvas) - canvas.style.opacity = '1'; update(); return; } + + // 1. Reset transitions instantly — force reflow to flush any running transition + canvas.style.transition = 'none'; overlay.style.transition = 'none'; + void canvas.offsetWidth; // force reflow + canvas.style.opacity = '0'; overlay.style.opacity = '1'; // 3. Update heatmap (invisible: canvas still at opacity 0) From 8a31b607167dbd0716be0ab1ba234a7e7b041cc4 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 22:07:34 +0100 Subject: [PATCH 14/24] =?UTF-8?q?fix:=20=C3=A9liminer=20le=20double=20affi?= =?UTF-8?q?chage=20du=20crossfade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/HeatMap.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index ef12da7..64148f7 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -91,26 +91,28 @@ export function HeatMap({ transactions }: HeatMapProps) { return; } - // 2. Freeze current frame in the overlay + // 1. Cancel any running overlay transition instantly (flush only the overlay) + overlay.style.transition = 'none'; + overlay.style.opacity = '0'; + void overlay.offsetWidth; // flush overlay only — avoids double-paint with canvas + + // 2. Capture current frame (canvas still visible at opacity 1) try { overlay.src = canvas.toDataURL(); } catch { - // canvas tainted (shouldn't happen with heatmap-only canvas) update(); return; } - // 1. Reset transitions instantly — force reflow to flush any running transition + // 3. In one paint batch: hide canvas + show overlay (Frame A moves from canvas to overlay) canvas.style.transition = 'none'; - overlay.style.transition = 'none'; - void canvas.offsetWidth; // force reflow canvas.style.opacity = '0'; overlay.style.opacity = '1'; - // 3. Update heatmap (invisible: canvas still at opacity 0) + // 4. Update heatmap (invisible: canvas at opacity 0) update(); - // 4. Simultaneous crossfade: overlay fades out, canvas fades in + // 5. Simultaneous crossfade: overlay fades out, canvas fades in const raf = requestAnimationFrame(() => { requestAnimationFrame(() => { const DURATION = '0.5s ease-in-out'; From 2debc3587af32790afa36a1b90657445e8b25e36 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 22:18:15 +0100 Subject: [PATCH 15/24] =?UTF-8?q?fix:=20crossfade=20simplifi=C3=A9=20?= =?UTF-8?q?=E2=80=94=20canvas=20toujours=20visible,=20seul=20l'overlay=20s?= =?UTF-8?q?e=20dissout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/HeatMap.tsx | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 64148f7..86831f2 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -91,12 +91,7 @@ export function HeatMap({ transactions }: HeatMapProps) { return; } - // 1. Cancel any running overlay transition instantly (flush only the overlay) - overlay.style.transition = 'none'; - overlay.style.opacity = '0'; - void overlay.offsetWidth; // flush overlay only — avoids double-paint with canvas - - // 2. Capture current frame (canvas still visible at opacity 1) + // 1. Freeze previous frame in overlay (canvas still at opacity 1) try { overlay.src = canvas.toDataURL(); } catch { @@ -104,22 +99,19 @@ export function HeatMap({ transactions }: HeatMapProps) { return; } - // 3. In one paint batch: hide canvas + show overlay (Frame A moves from canvas to overlay) - canvas.style.transition = 'none'; - canvas.style.opacity = '0'; + // 2. Snap overlay visible — cancel any running transition first + overlay.style.transition = 'none'; + void overlay.offsetWidth; // flush overlay.style.opacity = '1'; - // 4. Update heatmap (invisible: canvas at opacity 0) + // 3. Update canvas underneath (canvas always at opacity 1, covered by overlay) update(); - // 5. Simultaneous crossfade: overlay fades out, canvas fades in + // 4. After Leaflet redraws: dissolve overlay away, revealing new frame const raf = requestAnimationFrame(() => { requestAnimationFrame(() => { - const DURATION = '0.5s ease-in-out'; - overlay.style.transition = `opacity ${DURATION}`; + overlay.style.transition = 'opacity 0.5s ease-in-out'; overlay.style.opacity = '0'; - canvas.style.transition = `opacity ${DURATION}`; - canvas.style.opacity = '1'; }); }); From ac2f5bc431991ebc80cf1d85c5d5644eb715e619 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 22:25:26 +0100 Subject: [PATCH 16/24] =?UTF-8?q?fix:=20vrai=20crossfade=20simultan=C3=A9?= =?UTF-8?q?=20=E2=80=94=20canvas=20fade-in=20+=20overlay=20fade-out=20en?= =?UTF-8?q?=20m=C3=AAme=20temps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/HeatMap.tsx | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 86831f2..03cf5b5 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -65,7 +65,7 @@ export function HeatMap({ transactions }: HeatMapProps) { }; }, []); - // Crossfade: capture current heatmap → update underneath → fade out overlay + // True crossfade: old frame (overlay) fades out WHILE new frame (canvas) fades in simultaneously useEffect(() => { if (!heatRef.current || !mapRef.current) return; @@ -91,7 +91,7 @@ export function HeatMap({ transactions }: HeatMapProps) { return; } - // 1. Freeze previous frame in overlay (canvas still at opacity 1) + // 1. Capture old frame into overlay try { overlay.src = canvas.toDataURL(); } catch { @@ -99,18 +99,27 @@ export function HeatMap({ transactions }: HeatMapProps) { return; } - // 2. Snap overlay visible — cancel any running transition first + // 2. Snap overlay to visible, cancel any running transition overlay.style.transition = 'none'; - void overlay.offsetWidth; // flush overlay.style.opacity = '1'; - // 3. Update canvas underneath (canvas always at opacity 1, covered by overlay) + // 3. Hide canvas (new frame will fade in from here) + canvas.style.transition = 'none'; + canvas.style.opacity = '0'; + + // flush both + void overlay.offsetWidth; + + // 4. Draw new data onto canvas (invisible at opacity 0) update(); - // 4. After Leaflet redraws: dissolve overlay away, revealing new frame + // 5. Simultaneously: canvas fades in, overlay fades out → true crossfade const raf = requestAnimationFrame(() => { requestAnimationFrame(() => { - overlay.style.transition = 'opacity 0.5s ease-in-out'; + const DURATION = '0.55s ease-in-out'; + canvas.style.transition = `opacity ${DURATION}`; + canvas.style.opacity = '1'; + overlay.style.transition = `opacity ${DURATION}`; overlay.style.opacity = '0'; }); }); From bc61527b4e63f8c42182b7dfd066e8c6896faa46 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 22:43:12 +0100 Subject: [PATCH 17/24] =?UTF-8?q?fix:=20annuler=20les=20deux=20RAFs=20au?= =?UTF-8?q?=20cleanup=20pour=20=C3=A9viter=20la=20double=20transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/HeatMap.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 03cf5b5..4527dd6 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -114,8 +114,10 @@ export function HeatMap({ transactions }: HeatMapProps) { update(); // 5. Simultaneously: canvas fades in, overlay fades out → true crossfade - const raf = requestAnimationFrame(() => { - requestAnimationFrame(() => { + // Both RAF ids must be cancelled on cleanup to avoid double-transition + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { const DURATION = '0.55s ease-in-out'; canvas.style.transition = `opacity ${DURATION}`; canvas.style.opacity = '1'; @@ -124,7 +126,7 @@ export function HeatMap({ transactions }: HeatMapProps) { }); }); - return () => cancelAnimationFrame(raf); + return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; }, [transactions]); return ( From bea7cbe60f5bc7f582031f80dd0abbe6ad4515a1 Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 23:04:01 +0100 Subject: [PATCH 18/24] =?UTF-8?q?fix:=20crossfade=20via=20deux=20img=20ove?= =?UTF-8?q?rlays=20=E2=80=94=20canvas=20jamais=20modifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- src/components/HeatMap.tsx | 85 ++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 4527dd6..9a24a03 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -33,7 +33,12 @@ export function HeatMap({ transactions }: HeatMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const heatRef = useRef(null); - const overlayRef = useRef(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(null); + const nextRef = useRef(null); + // Src of the currently visible frame (so prev can be initialised correctly) + const currentSrcRef = useRef(''); // Initialize map once useEffect(() => { @@ -65,14 +70,16 @@ export function HeatMap({ transactions }: HeatMapProps) { }; }, []); - // True crossfade: old frame (overlay) fades out WHILE new frame (canvas) fades in simultaneously + // 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; const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas; - const overlay = overlayRef.current; + const prev = prevRef.current; + const next = nextRef.current; - const update = () => { + const draw = () => { const maxAmount = Math.max(...transactions.map((t) => t.amount), 1); const points: L.HeatLatLngTuple[] = transactions.map((tx) => [ tx.lat, @@ -86,43 +93,49 @@ export function HeatMap({ transactions }: HeatMapProps) { } }; - if (!canvas || !overlay) { - update(); + if (!canvas || !prev || !next) { + draw(); return; } - // 1. Capture old frame into overlay - try { - overlay.src = canvas.toDataURL(); - } catch { - update(); - 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'; - // 2. Snap overlay to visible, cancel any running transition - overlay.style.transition = 'none'; - overlay.style.opacity = '1'; + // next is hidden and will receive the incoming frame + next.style.transition = 'none'; + next.style.opacity = '0'; - // 3. Hide canvas (new frame will fade in from here) - canvas.style.transition = 'none'; - canvas.style.opacity = '0'; + void prev.offsetWidth; // flush CSS so transitions start cleanly - // flush both - void overlay.offsetWidth; + // Ask leaflet to draw new data (schedules an internal RAF) + draw(); - // 4. Draw new data onto canvas (invisible at opacity 0) - update(); - - // 5. Simultaneously: canvas fades in, overlay fades out → true crossfade - // Both RAF ids must be cancelled on cleanup to avoid double-transition + // --- 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(() => { - const DURATION = '0.55s ease-in-out'; - canvas.style.transition = `opacity ${DURATION}`; - canvas.style.opacity = '1'; - overlay.style.transition = `opacity ${DURATION}`; - overlay.style.opacity = '0'; + 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'; }); }); @@ -132,12 +145,20 @@ export function HeatMap({ transactions }: HeatMapProps) { return (
+ {/* prev: outgoing frame */} + {/* next: incoming frame — sits on top of prev during crossfade */} +
); } From 45080d83ac73e2e70662ffa608a9b735794fae9c Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 23:13:00 +0100 Subject: [PATCH 19/24] =?UTF-8?q?feat:=20afficher=20l'=C3=A9quivalent=20DU?= =?UTF-8?q?=20pour=20le=20volume=20total=20et=20la=20moyenne=20de=20transa?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 5 ++++- src/components/StatsPanel.tsx | 16 ++++++++++++++-- src/services/DataService.ts | 20 ++++++++++++++++++-- src/services/adapters/SubsquidAdapter.ts | 21 +++++++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 87db7d3..4bb5df8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ export default function App() { const [refreshing, setRefreshing] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); const [source, setSource] = useState<'live' | 'mock'>('mock'); + const [currentUD, setCurrentUD] = useState(11.78); const animation = useAnimation(transactions, periodDays); @@ -32,11 +33,12 @@ export default function App() { if (showLoading) setLoading(true); else setRefreshing(true); fetchData(periodDays) - .then(({ transactions, stats, source }) => { + .then(({ transactions, stats, source, currentUD }) => { if (!cancelled) { setTransactions(transactions); setStats(stats); setSource(source); + setCurrentUD(currentUD); setLastUpdate(new Date()); } }) @@ -68,6 +70,7 @@ export default function App() { loading={loading} periodDays={periodDays} source={source} + currentUD={currentUD} animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined} /> diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index b5fb36c..bebedaa 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -6,6 +6,7 @@ interface StatsPanelProps { loading: boolean; periodDays: number; source: 'live' | 'mock'; + currentUD: number; animationLabel?: string; } @@ -25,7 +26,14 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string; ); } -export function StatsPanel({ stats, loading, periodDays, source, animationLabel }: 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(null); @@ -89,12 +97,16 @@ export function StatsPanel({ stats, loading, periodDays, source, animationLabel prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null} /> { + 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 */} diff --git a/src/services/DataService.ts b/src/services/DataService.ts index c0c1dec..3c14530 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -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 { + 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; expiresAt: number } | null = null; @@ -106,6 +116,7 @@ export interface DataResult { transactions: Transaction[]; // uniquement géolocalisées → heatmap stats: PeriodStats; source: 'live' | 'mock'; + currentUD: number; // valeur du DU courant en Ğ1 } export async function fetchData(periodDays: number): Promise { @@ -117,10 +128,14 @@ export async function fetchData(periodDays: number): Promise { transactions, stats: { ...base, geoCount: transactions.length }, source: 'mock', + currentUD: 11.78, }; } - const { geolocated, totalCount, totalVolume } = await fetchLiveTransactions(periodDays); + const [{ geolocated, totalCount, totalVolume }, currentUD] = await Promise.all([ + fetchLiveTransactions(periodDays), + getCurrentUD(), + ]); const base = computeStats(geolocated); return { @@ -132,5 +147,6 @@ export async function fetchData(periodDays: number): Promise { topCities: base.topCities, }, source: 'live', + currentUD, }; } diff --git a/src/services/adapters/SubsquidAdapter.ts b/src/services/adapters/SubsquidAdapter.ts index 2b76998..ecc9edb 100644 --- a/src/services/adapters/SubsquidAdapter.ts +++ b/src/services/adapters/SubsquidAdapter.ts @@ -153,6 +153,27 @@ export async function buildIdentityKeyMap(): Promise> { return result; } +/** Retourne la valeur du DU courant en Ğ1 (ex : 11.78). Fallback hardcodé si indisponible. */ +export async function fetchCurrentUD(): Promise { + 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; From a2fdad46d41cfed6cda42e7ce28749916c77e77c Mon Sep 17 00:00:00 2001 From: syoul Date: Mon, 23 Mar 2026 23:17:10 +0100 Subject: [PATCH 20/24] =?UTF-8?q?feat:=20bouton=20Personnaliser=20pour=20p?= =?UTF-8?q?=C3=A9riode=20personnalis=C3=A9e=20(1=E2=80=93365=20jours)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/PeriodSelector.tsx | 71 +++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/src/components/PeriodSelector.tsx b/src/components/PeriodSelector.tsx index f043cc0..91169cb 100644 --- a/src/components/PeriodSelector.tsx +++ b/src/components/PeriodSelector.tsx @@ -1,3 +1,5 @@ +import { useState, useRef, useEffect } from 'react'; + interface PeriodSelectorProps { value: number; onChange: (days: number) => void; @@ -11,16 +13,40 @@ const PERIODS = [ { label: '30 jours', days: 30 }, ]; +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(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 ( -
+
{PERIODS.map(({ label, days }) => ( ))} +
+ + {/* Bouton Personnaliser + champ inline */} + {customOpen ? ( +
+ 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" + /> + j +
+ ) : ( + + )} + +
+