32 Commits

Author SHA1 Message Date
syoul 8e208d02ab chore: bump version to 1.2.0
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:11:05 +01:00
syoul 16cebb6ec9 feat: adaptation mobile — drawer bottom + layout responsive
ci/woodpecker/push/woodpecker Pipeline was successful
Sur smartphone (< 640px) : panneau stats masqué par défaut, accessible
via un bottom drawer animé (bouton ☰). PeriodSelector passe en flex-wrap
avec padding tactile 44px. AnimationPlayer s'adapte à la largeur écran.
Badge ville focus affiché directement sur la carte en mode mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:07:48 +01:00
syoul b6cb0af722 feat: clustering géographique des villes dans la vue Flux
ci/woodpecker/push/woodpecker Pipeline was successful
Regroupe les villes proches visuellement (CLUSTER_RADIUS = 38px) en un
seul nœud dont la couleur reflète la balance nette agrégée du groupe.
Affiche +N à l'intérieur des cercles multi-villes. Les arcs intra-cluster
sont ignorés. Le clustering se recalcule dynamiquement à chaque zoom/pan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:20:00 +01:00
syoul 78b4762c88 feat: coloration des nœuds par balance nette (orange émetteur / vert récepteur)
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:55:08 +01:00
syoul 136571ed53 chore: bump version 1.1.0
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:32:26 +01:00
syoul b884884a04 fix: heatmap overlay — masque les snapshots pendant zoom/pan, resync après
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:28:31 +01:00
syoul 97ff22027c feat: vue flux — arcs dirigés entre villes géolocalisées
ci/woodpecker/push/woodpecker Pipeline was successful
- Nouveau type TransactionArc + buildCorridors + computeFlowStats
- FlowMap : SVG overlay Leaflet, arcs bezier, flèches de direction, nœuds de villes cliquables
- Clic sur une ville : arcs sortants orange, entrants teal, reste grisé
- DataService : résolution géo des destinataires (toId) dans le même appel Cesium+
- useAnimation : expose visibleArcs filtré par frame
- PeriodSelector : bouton toggle Heatmap / Flux
- StatsPanel : stats flux (volume, top émetteurs, top récepteurs, balance nette)
- App : state viewMode + focusCity, FlowMap conditionnel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:21:03 +01:00
syoul ab72d8218b merge: sync main → dev (v1.0.0 + version display) 2026-03-24 00:08:32 +01:00
syoul 57c1888346 feat: affiche la version du build dans le header (v1.0.0)
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:03:48 +01:00
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 21441c4550 chore: merge dev → main v0.2.0
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-23 20:20:41 +01:00
15 changed files with 1149 additions and 157 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "g1flux",
"private": true,
"version": "0.0.0",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite",
+102 -16
View File
@@ -1,30 +1,46 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { StatsPanel } from './components/StatsPanel';
import { PeriodSelector } from './components/PeriodSelector';
import { HeatMap } from './components/HeatMap';
import { FlowMap } from './components/FlowMap';
import { AnimationPlayer } from './components/AnimationPlayer';
import { fetchData } from './services/DataService';
import type { PeriodStats } from './services/DataService';
import type { Transaction } from './data/mockData';
import type { TransactionArc } from './data/arcData';
import { computeStats } from './data/mockData';
import { computeFlowStats } from './data/arcData';
import { useAnimation } from './hooks/useAnimation';
import { useMediaQuery } from './hooks/useMediaQuery';
export default function App() {
const [periodDays, setPeriodDays] = useState(7);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [arcs, setArcs] = useState<TransactionArc[]>([]);
const [stats, setStats] = useState<PeriodStats | null>(null);
const [loading, setLoading] = useState(true);
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 [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
const [focusCity, setFocusCity] = useState<string | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const isMobile = useMediaQuery('(max-width: 639px)');
const animation = useAnimation(transactions, periodDays);
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
const handlePeriodChange = (days: number) => {
animation.deactivate();
setPeriodDays(days);
};
const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
setViewMode(mode);
setFocusCity(null);
};
useEffect(() => {
let cancelled = false;
@@ -32,11 +48,14 @@ export default function App() {
if (showLoading) setLoading(true);
else setRefreshing(true);
fetchData(periodDays)
.then(({ transactions, stats, source }) => {
.then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => {
if (!cancelled) {
setTransactions(transactions);
setArcs(arcs);
setStats(stats);
setSource(source);
setCurrentUD(currentUD);
setAllTimestamps(allTimestamps);
setLastUpdate(new Date());
}
})
@@ -52,41 +71,86 @@ export default function App() {
return () => { cancelled = true; clearInterval(interval); };
}, [periodDays]);
// Stats calculées sur la fenêtre courante en mode animation
// Stats heatmap sur la fenêtre courante en mode animation
const visibleStats: PeriodStats | null = animation.active
? {
...computeStats(animation.visibleTransactions),
geoCount: animation.visibleTransactions.length,
transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length,
}
: stats;
// Stats flux (recalculées sur les arcs visibles)
const flowStats = useMemo(
() => {
const activeArcs = animation.active ? animation.visibleArcs : arcs;
return activeArcs.length > 0 ? computeFlowStats(activeArcs) : null;
},
[arcs, animation.visibleArcs, animation.active],
);
const statsPanelProps = {
stats: visibleStats,
loading,
periodDays,
source,
currentUD,
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
viewMode,
flowStats,
focusCity,
};
return (
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
{/* Side panel */}
<StatsPanel
stats={visibleStats}
loading={loading}
periodDays={periodDays}
source={source}
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
/>
{/* Side panel — desktop uniquement */}
{!isMobile && <StatsPanel {...statsPanelProps} />}
{/* Map area */}
<div className="relative flex-1 min-w-0">
<HeatMap transactions={animation.visibleTransactions} />
{viewMode === 'heatmap' ? (
<HeatMap transactions={animation.visibleTransactions} />
) : (
<FlowMap
arcs={animation.active ? animation.visibleArcs : arcs}
focusCity={focusCity}
onCityClick={setFocusCity}
/>
)}
{/* Bouton menu — mobile uniquement */}
{isMobile && (
<button
onClick={() => setPanelOpen(true)}
className="absolute top-4 left-4 z-[1001] w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#d4a843] text-lg"
aria-label="Ouvrir le panneau"
>
</button>
)}
{/* Period selector — floating over map */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
<PeriodSelector
value={periodDays}
onChange={handlePeriodChange}
animationActive={animation.active}
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
/>
</div>
{/* Transaction count + source badge (masqués en mode animation) */}
{!loading && !animation.active && (
{/* Badge ville focus — mobile uniquement */}
{isMobile && focusCity && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1001] flex items-center gap-2 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#d4a843]/40 rounded-full px-3 py-1.5">
<span className="text-[#d4a843] text-xs font-medium">{focusCity}</span>
<button onClick={() => setFocusCity(null)} className="text-[#4b5563] hover:text-white text-xs leading-none"></button>
</div>
)}
{/* Transaction count + source badge (masqués sur mobile et en mode animation) */}
{!loading && !animation.active && !isMobile && (
<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
@@ -128,6 +192,28 @@ export default function App() {
</div>
)}
</div>
{/* Bottom drawer — mobile uniquement */}
{isMobile && (
<>
{/* Overlay */}
{panelOpen && (
<div
className="fixed inset-0 z-[1009] bg-black/50"
onClick={() => setPanelOpen(false)}
/>
)}
{/* Drawer */}
<div
className={`fixed bottom-0 left-0 right-0 z-[1010] h-[85vh] flex flex-col transition-transform duration-300 ${panelOpen ? 'translate-y-0' : 'translate-y-full'}`}
>
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
<div className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
</div>
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} />
</div>
</>
)}
</div>
);
}
+3 -3
View File
@@ -26,8 +26,8 @@ export function AnimationPlayer({
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">
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,calc(100vw-1rem))]">
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-4 py-3 flex flex-col gap-2.5 shadow-xl">
{/* Frame label + position */}
<div className="flex items-center justify-between">
@@ -50,7 +50,7 @@ export function AnimationPlayer({
/>
{/* Controls row */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-y-2">
{/* Playback buttons */}
<div className="flex items-center gap-1">
+343
View File
@@ -0,0 +1,343 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import L from 'leaflet';
/** Interpolation RGB linéaire entre deux couleurs hex, t ∈ [0, 1]. */
function lerpColor(hex1: string, hex2: string, t: number): string {
const parse = (h: string) => [
parseInt(h.slice(1, 3), 16),
parseInt(h.slice(3, 5), 16),
parseInt(h.slice(5, 7), 16),
];
const [r1, g1, b1] = parse(hex1);
const [r2, g2, b2] = parse(hex2);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
const COLOR_NEG = '#ff6d00'; // orange vif
const COLOR_POS = '#00c853'; // vert vif
const NEUTRAL_THRESHOLD = 0.05; // ±5 % → couleur neutre
const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux villes
import type { TransactionArc } from '../data/arcData';
import { buildCorridors } from '../data/arcData';
// Leaflet default marker fix (Vite asset pipeline)
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconShadowUrl from 'leaflet/dist/images/marker-shadow.png';
L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl });
interface FlowMapProps {
arcs: TransactionArc[];
focusCity: string | null;
onCityClick: (city: string | null) => void;
}
export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const [mapReady, setMapReady] = useState(false);
const [tick, setTick] = useState(0); // incrémenté sur moveend/zoomend → re-render
// Initialisation Leaflet
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, {
center: [46.8, 2.35],
zoom: 6,
zoomControl: false,
attributionControl: true,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 18,
}).addTo(map);
L.control.zoom({ position: 'bottomright' }).addTo(map);
mapRef.current = map;
setMapReady(true);
return () => {
map.remove();
mapRef.current = null;
setMapReady(false);
};
}, []);
// Re-projette le SVG à chaque déplacement/zoom
useEffect(() => {
if (!mapReady || !mapRef.current) return;
const onMove = () => setTick(t => t + 1);
mapRef.current.on('moveend zoomend', onMove);
return () => { mapRef.current?.off('moveend zoomend', onMove); };
}, [mapReady]);
// Agrégation en corridors
const corridors = useMemo(() => buildCorridors(arcs), [arcs]);
// Nœuds de villes (volume entrant + sortant)
const cityNodes = useMemo(() => {
const map = new Map<string, { lat: number; lng: number; emitted: number; received: number }>();
for (const c of corridors) {
if (!map.has(c.fromCity)) map.set(c.fromCity, { lat: c.fromLat, lng: c.fromLng, emitted: 0, received: 0 });
map.get(c.fromCity)!.emitted += c.totalVolume;
if (!map.has(c.toCity)) map.set(c.toCity, { lat: c.toLat, lng: c.toLng, emitted: 0, received: 0 });
map.get(c.toCity)!.received += c.totalVolume;
}
return map;
}, [corridors]);
// Projection SVG + clustering (recalculée sur chaque tick, changement d'arcs ou de focusCity)
const svgElements = useMemo(() => {
const m = mapRef.current;
if (!m || !mapReady) return null;
const proj = (lat: number, lng: number) => {
const p = m.latLngToContainerPoint([lat, lng]);
return { x: p.x, y: p.y };
};
// --- 1. Projeter toutes les villes en pixels, triées par volume desc ---
type CityPx = {
name: string; lat: number; lng: number;
x: number; y: number;
emitted: number; received: number; vol: number;
};
const cityList: CityPx[] = [...cityNodes.entries()].map(([name, d]) => {
const p = proj(d.lat, d.lng);
return { name, lat: d.lat, lng: d.lng, x: p.x, y: p.y, emitted: d.emitted, received: d.received, vol: d.emitted + d.received };
}).sort((a, b) => b.vol - a.vol);
// --- 2. Clustering glouton par distance pixel ---
interface Cluster {
cx: number; cy: number; // centroïde pondéré (pixels)
lat: number; lng: number; // centroïde géo (pour debug éventuel)
totalVol: number;
emitted: number; received: number;
cities: Set<string>;
}
const clusters: Cluster[] = [];
const cityClusterIdx = new Map<string, number>(); // nom ville → index cluster
for (const city of cityList) {
let bestIdx = -1;
let bestDist = Infinity;
for (let i = 0; i < clusters.length; i++) {
const cl = clusters[i];
const dx = city.x - cl.cx;
const dy = city.y - cl.cy;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < CLUSTER_RADIUS && d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
if (bestIdx === -1) {
// Nouvelle graine
clusters.push({
cx: city.x, cy: city.y,
lat: city.lat, lng: city.lng,
totalVol: city.vol,
emitted: city.emitted, received: city.received,
cities: new Set([city.name]),
});
cityClusterIdx.set(city.name, clusters.length - 1);
} else {
// Fusionner dans le cluster existant (centroïde pondéré)
const cl = clusters[bestIdx];
const newVol = cl.totalVol + city.vol;
cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol;
cl.cy = (cl.cy * cl.totalVol + city.y * city.vol) / newVol;
cl.totalVol = newVol;
cl.emitted += city.emitted;
cl.received += city.received;
cl.cities.add(city.name);
cityClusterIdx.set(city.name, bestIdx);
}
}
// --- 3. Agréger les corridors en arcs inter-clusters ---
interface ClusterArc {
fromIdx: number; toIdx: number;
totalVolume: number; count: number;
}
const clArcMap = new Map<string, ClusterArc>();
for (const c of corridors) {
const fi = cityClusterIdx.get(c.fromCity);
const ti = cityClusterIdx.get(c.toCity);
if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré
const key = `${fi}||${ti}`;
if (!clArcMap.has(key)) clArcMap.set(key, { fromIdx: fi, toIdx: ti, totalVolume: 0, count: 0 });
const ca = clArcMap.get(key)!;
ca.totalVolume += c.totalVolume;
ca.count += c.count;
}
const clusterArcs = [...clArcMap.values()].sort((a, b) => b.totalVolume - a.totalVolume);
// --- 4. Couleur de balance par cluster ---
const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1);
const clusterColors = clusters.map(cl => {
const net = cl.received - cl.emitted;
const t = net / maxAbsNet;
if (Math.abs(t) < NEUTRAL_THRESHOLD) return COLOR_NEUTRAL;
return t < 0
? lerpColor(COLOR_NEUTRAL, COLOR_NEG, -t)
: lerpColor(COLOR_NEUTRAL, COLOR_POS, t);
});
// Cluster de la ville focus
const focusClusterIdx = focusCity !== null ? (cityClusterIdx.get(focusCity) ?? -1) : -1;
// --- 5. Éléments SVG des arcs ---
const maxVol = Math.max(...clusterArcs.map(a => a.totalVolume), 1);
const arcElems = clusterArcs.map((ca, idx) => {
const p1 = { x: clusters[ca.fromIdx].cx, y: clusters[ca.fromIdx].cy };
const p2 = { x: clusters[ca.toIdx].cx, y: clusters[ca.toIdx].cy };
const mx = (p1.x + p2.x) / 2;
const my = (p1.y + p2.y) / 2;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const CURVE = 0.28;
const cx = mx - dy * CURVE;
const cy = my + dx * CURVE;
const t = 0.5;
const ax = (1-t)*(1-t)*p1.x + 2*(1-t)*t*cx + t*t*p2.x;
const ay = (1-t)*(1-t)*p1.y + 2*(1-t)*t*cy + t*t*p2.y;
const tln = Math.sqrt(dx*dx + dy*dy) || 1;
const nx = dx / tln; const ny = dy / tln;
const px = -ny; const py = nx;
const AR = 5;
const arrowPts = [
`${ax + nx*AR},${ay + ny*AR}`,
`${ax - nx*AR*0.6 + px*AR*0.5},${ay - ny*AR*0.6 + py*AR*0.5}`,
`${ax - nx*AR*0.6 - px*AR*0.5},${ay - ny*AR*0.6 - py*AR*0.5}`,
].join(' ');
const ratio = ca.totalVolume / maxVol;
const strokeW = Math.max(1, 1.5 + Math.log1p(ca.totalVolume) * 0.8);
const opacity = 0.35 + 0.55 * ratio;
const isFocusFrom = focusClusterIdx !== -1 && ca.fromIdx === focusClusterIdx;
const isFocusTo = focusClusterIdx !== -1 && ca.toIdx === focusClusterIdx;
const stroke = focusClusterIdx === -1 ? `url(#grad${idx})`
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
const arrowFill = focusClusterIdx === -1 ? '#e65100'
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
return {
idx, ca, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`,
};
});
// --- 6. Éléments SVG des nœuds de clusters ---
const maxClVol = Math.max(...clusters.map(cl => cl.totalVol), 1);
const nodeElems = clusters.map((cl, idx) => {
const r = Math.max(4, Math.min(18, 4 + 11 * (cl.totalVol / maxClVol)));
const fillColor = clusterColors[idx];
const isSelected = focusClusterIdx === idx;
const cityCount = cl.cities.size;
// Nom affiché : ville principale (la première dans l'itération = la plus volumineuse)
const label = cityCount > 1 ? `+${cityCount}` : [...cl.cities][0];
return { idx, cl, r, fillColor, isSelected, cityCount, label };
});
return { arcElems, nodeElems };
// tick en dep pour re-projeter sur pan/zoom
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [corridors, cityNodes, focusCity, tick, mapReady]);
// Handler de clic : on transmet la première ville du cluster cliqué
const handleNodeClick = (nodeIdx: number) => {
if (!svgElements) return;
const node = svgElements.nodeElems[nodeIdx];
const firstCity = [...node.cl.cities][0];
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
onCityClick(isCurrentFocus ? null : firstCity);
};
return (
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
<div ref={containerRef} className="absolute inset-0" />
{mapReady && svgElements && (
<svg
className="absolute inset-0 w-full h-full"
style={{ zIndex: 500, pointerEvents: 'none' }}
>
{/* Dégradés or→orange pour les arcs (aucune ville sélectionnée) */}
<defs>
{svgElements.arcElems.map(a => (
<linearGradient
key={`grad${a.idx}`}
id={`grad${a.idx}`}
x1={a.p1.x} y1={a.p1.y}
x2={a.p2.x} y2={a.p2.y}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#d4a843" />
<stop offset="100%" stopColor="#e65100" />
</linearGradient>
))}
</defs>
{/* Arcs bezier */}
{svgElements.arcElems.map(a => (
<g key={`${a.ca.fromIdx}-${a.ca.toIdx}`} opacity={a.opacity}>
<path
d={a.path}
fill="none"
stroke={a.stroke}
strokeWidth={a.strokeW}
strokeLinecap="round"
/>
<polygon points={a.arrowPts} fill={a.arrowFill} />
</g>
))}
{/* Nœuds de clusters (pointer-events activés uniquement ici) */}
<g style={{ pointerEvents: 'all' }}>
{svgElements.nodeElems.map(node => (
<g key={node.idx} onClick={() => handleNodeClick(node.idx)} style={{ cursor: 'pointer' }}>
<circle
cx={node.cl.cx}
cy={node.cl.cy}
r={node.r}
fill={node.isSelected ? '#ffffff' : node.fillColor}
stroke={node.isSelected ? node.fillColor : '#0a0b0f'}
strokeWidth={1.5}
/>
{node.cityCount > 1 && (
<text
x={node.cl.cx}
y={node.cl.cy + 3.5}
textAnchor="middle"
fontSize={node.r > 9 ? 9 : 7}
fontWeight="bold"
fill={node.isSelected ? node.fillColor : '#0a0b0f'}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{node.label}
</text>
)}
</g>
))}
</g>
</svg>
)}
</div>
);
}
+120 -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(() => {
@@ -57,39 +63,135 @@ export function HeatMap({ transactions }: HeatMapProps) {
mapRef.current = map;
heatRef.current = heat;
// Pendant zoom/pan : cache les overlays → le canvas live est visible directement.
// Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné.
const hideOverlays = () => {
const prev = prevRef.current;
const next = nextRef.current;
if (prev) { prev.style.transition = 'none'; prev.style.opacity = '0'; }
if (next) { next.style.transition = 'none'; next.style.opacity = '0'; }
currentSrcRef.current = '';
};
const syncAfterMove = () => {
const canvas = (heat as unknown as { _canvas?: HTMLCanvasElement })._canvas;
const next = nextRef.current;
if (!canvas || !next) return;
// Double RAF : leaflet.heat redessine en interne après l'événement
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
const src = canvas.toDataURL();
currentSrcRef.current = src;
next.src = src;
next.style.transition = 'none';
next.style.opacity = '1';
} catch { /* map torn down */ }
});
});
};
map.on('zoomstart movestart', hideOverlays);
map.on('zoomend moveend', syncAfterMove);
return () => {
map.off('zoomstart movestart', hideOverlays);
map.off('zoomend moveend', syncAfterMove);
map.remove();
mapRef.current = null;
heatRef.current = null;
};
}, []);
// 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>
);
}
+87 -5
View File
@@ -1,8 +1,12 @@
import { useState, useRef, useEffect } from 'react';
interface PeriodSelectorProps {
value: number;
onChange: (days: number) => void;
animationActive: boolean;
onAnimate: () => void;
viewMode: 'heatmap' | 'flow';
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
}
const PERIODS = [
@@ -11,16 +15,40 @@ const PERIODS = [
{ label: '30 jours', days: 30 },
];
export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) {
const PRESET_DAYS = new Set([1, 7, 30]);
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange }: 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 flex-wrap gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center max-w-[calc(100vw-2rem)]">
{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
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${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]'
}
@@ -29,7 +57,46 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }:
{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-2.5 sm: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={`
@@ -42,6 +109,21 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }:
>
Animer
</button>
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
<button
onClick={() => onViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap')}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${viewMode === 'flow'
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
`}
>
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
</button>
</div>
);
}
+215 -72
View File
@@ -1,12 +1,18 @@
import { useRef } from 'react';
import type { PeriodStats } from '../services/DataService';
import type { FlowStats } from '../data/arcData';
interface StatsPanelProps {
stats: PeriodStats | null;
loading: boolean;
periodDays: number;
source: 'live' | 'mock';
currentUD: number;
animationLabel?: string;
viewMode?: 'heatmap' | 'flow';
flowStats?: FlowStats | null;
focusCity?: string | null;
onClose?: () => void;
}
const MEDALS = ['🥇', '🥈', '🥉'];
@@ -25,7 +31,35 @@ 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`;
}
function CityRow({ city, volume, count, countryCode, accent }: {
city: string; volume: number; count: number; countryCode: string; accent?: string;
}) {
return (
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-white text-xs font-medium truncate">
{countryCode && (
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none shrink-0">
{countryCode}
</span>
)}
<span className="truncate">{city}</span>
</span>
<span className={`text-xs font-mono shrink-0 ml-1 ${accent ?? 'text-[#d4a843]'}`}>
{volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
<span className="text-[#4b5563] ml-0.5">· {count}</span>
</span>
</div>
);
}
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
@@ -47,21 +81,30 @@ export function StatsPanel({ stats, loading, periodDays, source, animationLabel
// 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">
<aside className="w-full lg:w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto h-full">
{/* Header */}
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
Ğ
</div>
<div>
<h1 className="text-white font-bold text-lg leading-none">Ğ1Flux</h1>
<h1 className="text-white font-bold text-lg leading-none">
Ğ1Flux
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
</h1>
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
</div>
{onClose && (
<button
onClick={onClose}
className="ml-auto text-[#4b5563] hover:text-white transition-colors p-1 text-lg leading-none"
aria-label="Fermer"
>
</button>
)}
</div>
{/* Description */}
@@ -77,77 +120,177 @@ export function StatsPanel({ stats, loading, periodDays, source, animationLabel
}
</p>
{/* Stats */}
{loading ? (
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
))}
</div>
) : stats ? (
<div className="space-y-3">
<StatCard
label="Volume total"
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
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`}
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>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
{/* ---- Vue HEATMAP ---- */}
{viewMode === 'heatmap' && (
<>
{loading ? (
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
))}
</div>
) : stats ? (
<div className="space-y-3">
<StatCard
label="Volume total"
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
sub={formatDU(stats.totalVolume, currentUD)}
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
/>
<StatCard
label="Transactions"
value={stats.transactionCount.toLocaleString('fr-FR')}
sub={(() => {
const avg = stats.totalVolume / (stats.transactionCount || 1);
return `${avg.toFixed(2)} Ğ1 / tx · ${formatDU(avg, currentUD)} / tx`;
})()}
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
/>
{/* Couverture géo — transactionCount inclut le total réel de la frame */}
{source === 'live' && stats.transactionCount > 0 && (() => {
const pct = Math.round((stats.geoCount / stats.transactionCount) * 100);
return (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
<div className="flex justify-between items-center mb-1.5">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
<p className="text-[#6b7280] text-xs">{stats.geoCount} / {stats.transactionCount}</p>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
<div
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
</div>
);
})()}
</div>
) : null}
{/* Top cities */}
{!loading && stats && stats.topCities.length > 0 && (
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest border-t border-[#1e1f2a] pt-3">
Top villes
</p>
{stats.topCities.map((city, i) => (
<div
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>
key={city.name}
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex gap-2.5"
>
<span className="text-base shrink-0 mt-0.5">{MEDALS[i]}</span>
<div className="flex-1 min-w-0">
<p className="text-white text-xs font-medium leading-snug">{city.name}</p>
<div className="flex items-center justify-between mt-1">
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
{city.countryCode && (
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
{city.countryCode}
</span>
)}
{city.count} tx
</span>
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
{delta(city.volume, prevCityVolume, city.name)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
) : null}
</>
)}
{/* Top cities */}
{!loading && stats && stats.topCities.length > 0 && (
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest border-t border-[#1e1f2a] pt-3">
Top villes
</p>
{stats.topCities.map((city, i) => (
<div
key={city.name}
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex gap-2.5"
>
<span className="text-base shrink-0 mt-0.5">{MEDALS[i]}</span>
<div className="flex-1 min-w-0">
<p className="text-white text-xs font-medium leading-snug">{city.name}</p>
<div className="flex items-center justify-between mt-1">
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
{city.countryCode && (
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
{city.countryCode}
</span>
)}
{city.count} tx
</span>
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
{delta(city.volume, prevCityVolume, city.name)}
</span>
</div>
</div>
{/* ---- Vue FLUX ---- */}
{viewMode === 'flow' && (
<>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-16 animate-pulse" />
))}
</div>
))}
</div>
) : flowStats ? (
<div className="space-y-3">
<StatCard
label="Volume des flux"
value={`${flowStats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
sub={formatDU(flowStats.totalVolume, currentUD)}
/>
<StatCard
label="Arcs géolocalisés"
value={flowStats.arcCount.toLocaleString('fr-FR')}
sub={flowStats.arcCount > 0
? `${(flowStats.totalVolume / flowStats.arcCount).toFixed(2)} Ğ1 / arc`
: undefined}
/>
{/* Top émetteurs */}
{flowStats.topEmitters.length > 0 && (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Top émetteurs</p>
{flowStats.topEmitters.map((c, i) => (
<div key={c.city} className="flex items-center gap-2">
<span className="text-sm shrink-0">{MEDALS[i]}</span>
<CityRow city={c.city} volume={c.volume} count={c.count} countryCode={c.countryCode} accent="text-[#ff8f00]" />
</div>
))}
</div>
)}
{/* Top récepteurs */}
{flowStats.topReceivers.length > 0 && (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Top récepteurs</p>
{flowStats.topReceivers.map((c, i) => (
<div key={c.city} className="flex items-center gap-2">
<span className="text-sm shrink-0">{MEDALS[i]}</span>
<CityRow city={c.city} volume={c.volume} count={c.count} countryCode={c.countryCode} accent="text-[#00acc1]" />
</div>
))}
</div>
)}
{/* Balance nette */}
{flowStats.netBalance.length > 0 && (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
<div className="flex items-center justify-between">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
<p className="text-[10px] text-[#4b5563] flex items-center gap-1.5">
<span style={{ color: '#ff6d00' }}></span>émetteur
<span style={{ color: '#00c853' }}></span>récepteur
</p>
</div>
{flowStats.netBalance.map((c) => (
<div key={c.city} className="flex items-center justify-between">
<span className="text-white text-xs truncate">{c.city}</span>
<span className={`text-xs font-mono shrink-0 ml-2 ${c.net >= 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}>
{c.net >= 0 ? '+' : ''}{Math.round(c.net).toLocaleString('fr-FR')} Ğ1
</span>
</div>
))}
</div>
)}
{/* Ville focus */}
{focusCity && (
<div className="bg-[#0f1016] border border-[#d4a843]/30 rounded-xl p-3">
<p className="text-[#4b5563] text-xs uppercase tracking-widest mb-1">Ville sélectionnée</p>
<p className="text-[#d4a843] text-sm font-medium">{focusCity}</p>
<p className="text-[#4b5563] text-xs mt-0.5">
<span className="text-[#ff8f00]"></span> sortants &nbsp;
<span className="text-[#00acc1]"></span> entrants
</p>
</div>
)}
</div>
) : (
<p className="text-[#4b5563] text-xs">Aucun arc à afficher.</p>
)}
</>
)}
{/* Footer */}
+120
View File
@@ -0,0 +1,120 @@
import type { Transaction } from './mockData';
export interface TransactionArc {
id: string;
timestamp: number; // Unix ms
amount: number; // Ğ1
fromLat: number;
fromLng: number;
fromCity: string;
fromCountry: string;
fromKey: string;
toLat: number;
toLng: number;
toCity: string;
toCountry: string;
toKey: string;
}
/** Corridor agrégé par paire de villes (fromCity → toCity). */
export interface Corridor {
fromCity: string;
fromLat: number;
fromLng: number;
fromCountry: string;
toCity: string;
toLat: number;
toLng: number;
toCountry: string;
totalVolume: number;
count: number;
}
export interface FlowStats {
totalVolume: number;
arcCount: number;
topEmitters: { city: string; volume: number; count: number; countryCode: string }[];
topReceivers: { city: string; volume: number; count: number; countryCode: string }[];
netBalance: { city: string; net: number; countryCode: string }[];
}
/** Agrège les arcs individuels en corridors ville→ville, triés par volume. */
export function buildCorridors(arcs: TransactionArc[]): Corridor[] {
const map = new Map<string, Corridor>();
for (const arc of arcs) {
const key = `${arc.fromCity}||${arc.toCity}`;
if (!map.has(key)) {
map.set(key, {
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
totalVolume: 0, count: 0,
});
}
const c = map.get(key)!;
c.totalVolume += arc.amount;
c.count++;
}
return [...map.values()].sort((a, b) => b.totalVolume - a.totalVolume);
}
export function computeFlowStats(arcs: TransactionArc[]): FlowStats {
const emitters = new Map<string, { volume: number; count: number; country: string }>();
const receivers = new Map<string, { volume: number; count: number; country: string }>();
for (const arc of arcs) {
if (!emitters.has(arc.fromCity)) emitters.set(arc.fromCity, { volume: 0, count: 0, country: arc.fromCountry });
if (!receivers.has(arc.toCity)) receivers.set(arc.toCity, { volume: 0, count: 0, country: arc.toCountry });
emitters.get(arc.fromCity)!.volume += arc.amount;
emitters.get(arc.fromCity)!.count++;
receivers.get(arc.toCity)!.volume += arc.amount;
receivers.get(arc.toCity)!.count++;
}
const allCities = new Set([...emitters.keys(), ...receivers.keys()]);
const netBalance = [...allCities].map(city => ({
city,
net: (receivers.get(city)?.volume ?? 0) - (emitters.get(city)?.volume ?? 0),
countryCode: emitters.get(city)?.country ?? receivers.get(city)?.country ?? '',
})).sort((a, b) => Math.abs(b.net) - Math.abs(a.net)).slice(0, 5);
return {
totalVolume: arcs.reduce((s, a) => s + a.amount, 0),
arcCount: arcs.length,
topEmitters: [...emitters.entries()].sort((a, b) => b[1].volume - a[1].volume).slice(0, 3)
.map(([city, d]) => ({ city, volume: d.volume, count: d.count, countryCode: d.country })),
topReceivers: [...receivers.entries()].sort((a, b) => b[1].volume - a[1].volume).slice(0, 3)
.map(([city, d]) => ({ city, volume: d.volume, count: d.count, countryCode: d.country })),
netBalance,
};
}
/**
* Génère des arcs mock : chaque transaction devient un arc dont le destinataire
* est une transaction aléatoire d'une ville différente.
*/
export function buildMockArcs(transactions: Transaction[]): TransactionArc[] {
if (transactions.length < 2) return [];
const arcs: TransactionArc[] = [];
for (let i = 0; i < transactions.length; i++) {
if (Math.random() > 0.55) continue; // ~55 % de couverture
const from = transactions[i];
let toIdx = Math.floor(Math.random() * transactions.length);
for (let tries = 0; tries < 8 && transactions[toIdx].city === from.city; tries++) {
toIdx = Math.floor(Math.random() * transactions.length);
}
const to = transactions[toIdx];
if (to.city === from.city) continue;
arcs.push({
id: `${from.id}-arc`,
timestamp: from.timestamp,
amount: from.amount,
fromLat: from.lat, fromLng: from.lng,
fromCity: from.city, fromCountry: from.countryCode,
fromKey: from.fromKey,
toLat: to.lat, toLng: to.lng,
toCity: to.city, toCountry: to.countryCode,
toKey: to.toKey,
});
}
return arcs;
}
+30 -11
View File
@@ -1,5 +1,6 @@
import { useState, useMemo, useEffect } from 'react';
import type { Transaction } from '../data/mockData';
import type { TransactionArc } from '../data/arcData';
export interface TimeFrame {
label: string;
@@ -39,42 +40,43 @@ function buildFrames(periodDays: number): TimeFrame[] {
});
}
// 30 days → weekly frames
// 30 days → half-week frames (3.5 days ≈ 910 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, { weekday: 'short', day: 'numeric', month: 'short' })} ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
from,
to,
});
cursor = to;
week++;
}
return frames;
}
export function useAnimation(transactions: Transaction[], periodDays: number) {
export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], 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>(1);
const [speed, setSpeed] = useState<1 | 2 | 4>(2);
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
// Reset cursor and playback when period or activation changes
// 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);
setPlaying(false);
if (!active) setPlaying(false);
}, [periodDays, active]);
// 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) {
@@ -94,9 +96,24 @@ export function useAnimation(transactions: Transaction[], periodDays: number) {
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
}, [active, transactions, frames, currentIndex]);
const visibleArcs = useMemo(() => {
if (!active || frames.length === 0) return arcs;
const frame = frames[currentIndex];
if (!frame) return arcs;
return arcs.filter((a) => a.timestamp >= frame.from && a.timestamp < frame.to);
}, [active, arcs, 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),
activate: () => { setActive(true); setSpeed(1); setPlaying(true); },
deactivate: () => { setActive(false); },
playing,
play: () => setPlaying(true),
@@ -108,5 +125,7 @@ export function useAnimation(transactions: Transaction[], periodDays: number) {
frames,
currentFrame: frames[currentIndex] ?? null,
visibleTransactions,
visibleArcs,
frameTotalCount,
};
}
+12
View File
@@ -0,0 +1,12 @@
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [query]);
return matches;
}
+85 -28
View File
@@ -12,16 +12,30 @@
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/
import { fetchTransfers, buildIdentityKeyMap } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys } from './adapters/CesiumAdapter';
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter';
import {
getTransactionsForPeriod,
computeStats,
type Transaction,
} from '../data/mockData';
import {
buildMockArcs,
type TransactionArc,
} from '../data/arcData';
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;
@@ -33,12 +47,16 @@ async function getIdentityKeyMap(): Promise<Map<string, string>> {
}
async function fetchLiveTransactions(periodDays: number): Promise<{
geolocated: Transaction[];
totalCount: number;
totalVolume: number;
geolocated: Transaction[];
arcs: TransactionArc[];
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: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
@@ -50,15 +68,16 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
console.warn('Identity key map indisponible :', err);
}
// Clés Duniter uniques des émetteurs
const duniterKeys = [...new Set(
rawTransfers.map((t) => keyMap.get(t.fromId)).filter(Boolean) as string[]
)];
// Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+)
const allDuniterKeys = [...new Set([
...rawTransfers.map((t) => keyMap.get(t.fromId)),
...rawTransfers.map((t) => keyMap.get(t.toId)),
].filter(Boolean) as string[])];
// Résolution géo par clé Duniter (_id Cesium+)
let geoMap = new Map<string, { lat: number; lng: number; city: string; countryCode: string }>();
try {
const profiles = await resolveGeoByKeys(duniterKeys);
const profiles = await resolveGeoByKeys(allDuniterKeys);
for (const [key, p] of profiles) {
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode });
}
@@ -66,28 +85,53 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
console.warn('Cesium+ indisponible :', err);
}
// Seules les transactions avec un profil géo entrent dans le heatmap
const geolocated: Transaction[] = [];
for (const t of rawTransfers) {
const duniterKey = keyMap.get(t.fromId);
if (!duniterKey) continue;
const geo = geoMap.get(duniterKey);
if (!geo) continue;
const geolocated: Transaction[] = [];
const arcs: TransactionArc[] = [];
for (const t of rawTransfers) {
const fromDuniterKey = keyMap.get(t.fromId);
if (!fromDuniterKey) continue;
const fromGeo = geoMap.get(fromDuniterKey);
if (!fromGeo) continue;
const fromCity = cleanCityName(fromGeo.city);
// Heatmap : émetteur géolocalisé
geolocated.push({
id: t.id,
timestamp: t.timestamp,
lat: geo.lat,
lng: geo.lng,
lat: fromGeo.lat,
lng: fromGeo.lng,
amount: t.amount,
city: geo.city,
countryCode: geo.countryCode,
city: fromCity,
countryCode: fromGeo.countryCode,
fromKey: t.fromId,
toKey: t.toId,
});
// Arc : les deux extrémités géolocalisées + villes différentes
const toDuniterKey = keyMap.get(t.toId);
if (!toDuniterKey) continue;
const toGeo = geoMap.get(toDuniterKey);
if (!toGeo) continue;
const toCity = cleanCityName(toGeo.city);
if (fromCity === toCity) continue;
arcs.push({
id: `${t.id}-arc`,
timestamp: t.timestamp,
amount: t.amount,
fromLat: fromGeo.lat, fromLng: fromGeo.lng,
fromCity, fromCountry: fromGeo.countryCode,
fromKey: t.fromId,
toLat: toGeo.lat, toLng: toGeo.lng,
toCity, toCountry: toGeo.countryCode,
toKey: t.toId,
});
}
return { geolocated, totalCount, totalVolume };
return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
}
// ---------------------------------------------------------------------------
@@ -101,9 +145,12 @@ 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
arcs: TransactionArc[]; // les deux extrémités géolocalisées → vue flux
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> {
@@ -111,18 +158,26 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
await new Promise((r) => setTimeout(r, 80));
const transactions = getTransactionsForPeriod(periodDays);
const base = computeStats(transactions);
const arcs = buildMockArcs(transactions);
return {
transactions,
arcs,
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, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
fetchLiveTransactions(periodDays),
getCurrentUD(),
]);
const base = computeStats(geolocated);
return {
transactions: geolocated,
arcs,
stats: {
totalVolume,
transactionCount: totalCount,
@@ -130,5 +185,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;
+1
View File
@@ -0,0 +1 @@
declare const __APP_VERSION__: string;
+6
View File
@@ -1,8 +1,14 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { readFileSync } from 'node:fs'
const { version } = JSON.parse(readFileSync('./package.json', 'utf-8')) as { version: string };
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(version),
},
plugins: [
react(),
tailwindcss(),