46 Commits

Author SHA1 Message Date
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 3aa3933b4c fix: corrige la dérive temporelle du pool mock
ci/woodpecker/push/woodpecker Pipeline was successful
Les timestamps du pool étaient figés au moment du chargement du module.
On calcule le drift entre l'heure de génération et l'heure courante,
et on le réapplique à chaque appel à getTransactionsForPeriod.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:29:25 +01:00
syoul 21441c4550 chore: merge dev → main v0.2.0
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-23 20:20:41 +01:00
syoul 26e429c8c0 chore: ajoute docs-syoul/ au .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:14:39 +01:00
syoul 03f63aec46 feat: ajoute un descriptif de l'application dans le panneau latéral
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:09:08 +01:00
syoul ec06c4e35c style: footer dépôt et licence en jaune Ğ1
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:56:24 +01:00
syoul aa1f3d5f2f feat: ajoute dépôt et licence AGPLv3 dans le footer
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:53:27 +01:00
syoul 034e16ee37 chore: ajoute docs-plan/ au .gitignore
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:45:50 +01:00
syoul ead63f9459 feat: déploiement multi-branches avec URL par branche
ci/woodpecker/push/woodpecker Pipeline was successful
- Déclenche le pipeline sur main, dev et ci uniquement
- main → APP_DOMAIN (domaine racine, pas de préfixe)
- dev/ci → branche.APP_DOMAIN (sous-domaine par branche)
- Dossier de déploiement isolé par branche : /opt/g1flux/<branche>/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:22:29 +01:00
syoul 2499fac213 restart pipeline
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-22 20:04:02 +01:00
syoul debb4cf8ec feat: pipeline CI + Docker pour déploiement sur sonic
- Dockerfile multi-stage (node:20 build + nginx:alpine serve)
- nginx.conf SPA avec try_files et gzip
- docker-compose.yml avec labels Registrator/Fabio (SERVICE_80_CHECK_TCP)
- .woodpecker.yml : validate, security-check, build-image, SBOM, write-env,
  test-env, deploy (stop→acme→up), test-deploy, healthcheck
- .gitignore : ajout .env et /.reports/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:02:24 +01:00
syoul 94474fc007 fix: détection pays fiable via nom de ville Cesium+ + reorder bounding boxes
- Extrait le pays depuis le champ city Cesium+ en priorité (ex: "Heusy, 4800, Belgique" → BE)
- Bounding boxes réordonnées : petits pays (LU, BE, CH, NL) avant FR pour éviter les faux positifs
- Affiche l'heure du dernier refresh sur le badge live

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:08:22 +01:00
syoul d99ad3707d fix: show full city name with postal code and country in top villes
Restore full Cesium+ city field (including postal code), restructure
the city card so name wraps on two lines with country badge + tx count
+ volume all readable without truncation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:55:37 +01:00
syoul 55d2b50cd3 fix: replace emoji flags with text badges, clean city names from postal codes
Emoji flags render as boxes on Linux. Replace with a small FR/BE/CH
badge. Also strip postal codes from Cesium+ city names (e.g.
"Saint-Jean-de-Laur, 46260" → "Saint-Jean-de-Laur").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:53:42 +01:00
syoul 8d9a9a3c07 feat: show country flag next to city names in top villes
Determine country from geoPoint coordinates using bounding boxes
for the main Ğ1 community countries (FR, BE, CH, ES, DE, IT, ...).
Display the emoji flag before each city name in the top villes panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:52:00 +01:00
syoul a8792641ab feat: show up/down delta indicators on stats after each refresh
Volume total, transaction count, and top city volumes now display
↑ (green) or ↓ (red) arrows compared to the previous refresh,
making it visible that data is actually updating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:47:53 +01:00
syoul a6fc4a534f fix: use real totalCount from Subsquid instead of capped fetch limit
Add totalCount to the GraphQL query so transactionCount reflects the
true number of transfers in the period, not the 2000-item fetch cap.
This also fixes the average Ğ1/tx calculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:40:07 +01:00
syoul 2674b3891b fix: add error handling and refresh indicator to auto-refresh
- Add .catch() so failed background fetches don't silently break the interval
- Add refreshing state with a spinning ↻ on the live badge during background updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:34:47 +01:00
20 changed files with 1640 additions and 166 deletions
+9
View File
@@ -1,3 +1,8 @@
.env
.env.*
!.env.example
/.reports/
# Logs
logs
*.log
@@ -22,3 +27,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/docs-plan/
/docs-syoul/
/docs-bugs/
+216
View File
@@ -0,0 +1,216 @@
when:
- branch:
- main
- dev
- ci
event: push
steps:
# Etape 1 : Validation syntaxique du docker-compose.yml
- name: validate
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
APP_DOMAIN: validate.example.com
commands:
- |
export COMPOSE_PROJECT_NAME=$(printf '%s-%s-%s' "$CI_REPO_OWNER" "$CI_REPO_NAME" "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
docker compose config --quiet
- echo "docker-compose.yml valide"
# Etape 2 : Verifications de securite
- name: security-check
image: alpine:3.20
commands:
- |
if [ -f .env ]; then
echo "ERREUR: .env ne doit pas etre commite dans le depot"
exit 1
fi
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
- echo "Verifications de securite OK"
# Etape 3 : Build de l'image Docker en local sur sonic
# VITE_USE_LIVE_API=true est bake dans l'image au moment du build (Vite)
# NOTE: volumes + pas de from_secret : compatible
- name: build-image
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker build -t g1flux:latest .
- echo "Image g1flux:latest construite"
# Etape 4a : Generation SBOM (Syft) depuis l'image locale
# NOTE: volumes + pas de from_secret : compatible
- name: sbom-generate
image: alpine:3.20
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
- mkdir -p .reports
- syft g1flux:latest -o cyclonedx-json --file .reports/sbom-app.cyclonedx.json
- echo "SBOM genere"
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
- name: sbom-scan
image: aquasec/trivy:latest
volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy
commands:
- trivy sbom --format json --output .reports/trivy-app.json .reports/sbom-app.cyclonedx.json
- echo "Scan CVE termine"
# Etape 4c : Publication SBOM vers Dependency-Track
# NOTE: from_secret + pas de volumes : compatible
- name: sbom-publish
image: alpine/curl:latest
environment:
DTRACK_TOKEN:
from_secret: dependency_track_token
DTRACK_DOMAIN:
from_secret: dtrack_domain
commands:
- |
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
-H "X-Api-Key: $DTRACK_TOKEN" \
-F "autoCreate=true" \
-F "projectName=g1flux-app" \
-F "projectVersion=$VERSION" \
-F "bom=@.reports/sbom-app.cyclonedx.json")
echo "HTTP $HTTP : $(cat /tmp/dtrack-resp.txt)"
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
# Etape 5a : Ecriture du .env.deploy depuis les secrets
# NOTE: from_secret + pas de volumes : compatible
- name: write-env
image: alpine:3.20
environment:
APP_DOMAIN_BASE:
from_secret: app_domain
commands:
- |
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
EFFECTIVE_DOMAIN="$APP_DOMAIN_BASE"
else
EFFECTIVE_DOMAIN="$BRANCH.$APP_DOMAIN_BASE"
fi
OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z')
REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z')
echo "APP_DOMAIN=$EFFECTIVE_DOMAIN" > .env.deploy
echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy
- echo "Fichier .env.deploy cree ($(wc -c < .env.deploy) octets)"
# Etape 5b : Validation du .env.deploy
- name: test-env
image: alpine:3.20
commands:
- |
[ -f .env.deploy ] || { echo "FAIL: .env.deploy introuvable"; exit 1; }
echo "PASS: .env.deploy present"
- |
VAL=$(grep '^COMPOSE_PROJECT_NAME=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: COMPOSE_PROJECT_NAME vide" && exit 1
echo "PASS: COMPOSE_PROJECT_NAME = $VAL"
- |
VAL=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: APP_DOMAIN vide" && exit 1
echo "PASS: APP_DOMAIN = $VAL"
# Etape 6 : Deploiement sur sonic via Docker socket
# Fabio routing gere automatiquement par Registrator via les labels SERVICE_* du compose
# NOTE: volumes + pas de from_secret : compatible
- name: deploy
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/g1flux:/opt/g1flux
commands:
- |
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
DEPLOY_DIR="/opt/g1flux/$BRANCH"
mkdir -p "$DEPLOY_DIR"
cp .env.deploy "$DEPLOY_DIR/.env"
chmod 600 "$DEPLOY_DIR/.env"
cp docker-compose.yml "$DEPLOY_DIR/docker-compose.yml"
cd "$DEPLOY_DIR" && docker compose stop
- |
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
DEPLOY_DIR="/opt/g1flux/$BRANCH"
DOMAIN=$(grep '^APP_DOMAIN=' "$DEPLOY_DIR/.env" | cut -d= -f2)
# Certificat TLS (acme.sh, idempotent)
# Exit 0 = emis/renouvele, exit 2 = skip (cert valide), autres = erreur
ACME_EXIT=0
docker exec sonic-acme-1 /app/acme.sh \
--home /etc/acme.sh \
--issue -d "$DOMAIN" \
--webroot /usr/share/nginx/html \
--server letsencrypt \
--accountemail support+acme@asycn.io || ACME_EXIT=$?
if [ "$ACME_EXIT" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT)"
exit 1
fi
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
echo "Cert TLS OK (acme exit $ACME_EXIT)"
- |
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
DEPLOY_DIR="/opt/g1flux/$BRANCH"
cd "$DEPLOY_DIR" && docker compose up -d --remove-orphans
docker compose ps
# Etape 7 : Verification que le container est running
- name: test-deploy
image: docker:27-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/g1flux:/opt/g1flux
commands:
- |
BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-')
DEPLOY_DIR="/opt/g1flux/$BRANCH"
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' "$DEPLOY_DIR/.env" | cut -d= -f2)
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-app" 2>/dev/null || echo "absent")
echo "$PROJECT-app : $STATUS"
[ "$STATUS" = "running" ] || { echo "FAIL: container non running"; exit 1; }
echo "PASS: container running"
# Etape 8 : Healthcheck HTTP public
- name: healthcheck
image: alpine:3.20
commands:
- apk add --no-cache --quiet curl
- |
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
TARGET="https://$SITE"
echo "Healthcheck $TARGET..."
MAX=18
i=0
until [ $i -ge $MAX ]; do
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
echo "PASS: app repond sur $TARGET"
exit 0
fi
i=$((i+1))
sleep 10
done
echo "FAIL: app ne repond pas apres 3 minutes"
exit 1
# Notification en cas d'echec
- name: notify-failure
image: alpine:3.20
commands:
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure
+11
View File
@@ -0,0 +1,11 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN VITE_USE_LIVE_API=true npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+18
View File
@@ -0,0 +1,18 @@
name: ${COMPOSE_PROJECT_NAME:-syoul-g1flux-main}
services:
app:
image: g1flux:latest
container_name: ${COMPOSE_PROJECT_NAME:-syoul-g1flux-main}-app
restart: always
labels:
- SERVICE_80_NAME=${COMPOSE_PROJECT_NAME:-syoul-g1flux-main}-app-80
- SERVICE_80_TAGS=urlprefix-${APP_DOMAIN}/*
- SERVICE_80_CHECK_TCP=true
- LETSENCRYPT_HOST=${APP_DOMAIN}
networks:
- sonic
networks:
sonic:
external: true
+12
View File
@@ -0,0 +1,12 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "g1flux",
"private": true,
"version": "0.0.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
+109 -15
View File
@@ -1,31 +1,65 @@
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';
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 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;
const load = (showLoading: boolean) => {
if (showLoading) setLoading(true);
fetchData(periodDays).then(({ transactions, stats, source }) => {
if (!cancelled) {
setTransactions(transactions);
setStats(stats);
setSource(source);
setLoading(false);
}
});
else setRefreshing(true);
fetchData(periodDays)
.then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => {
if (!cancelled) {
setTransactions(transactions);
setArcs(arcs);
setStats(stats);
setSource(source);
setCurrentUD(currentUD);
setAllTimestamps(allTimestamps);
setLastUpdate(new Date());
}
})
.catch((err) => console.warn('Ğ1Flux refresh error:', err))
.finally(() => {
if (!cancelled) { setLoading(false); setRefreshing(false); }
});
};
load(true);
@@ -34,22 +68,65 @@ export default function App() {
return () => { cancelled = true; clearInterval(interval); };
}, [periodDays]);
// 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],
);
return (
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
{/* Side panel */}
<StatsPanel stats={stats} loading={loading} periodDays={periodDays} source={source} />
<StatsPanel
stats={visibleStats}
loading={loading}
periodDays={periodDays}
source={source}
currentUD={currentUD}
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
viewMode={viewMode}
flowStats={flowStats}
focusCity={focusCity}
/>
{/* Map area */}
<div className="relative flex-1 min-w-0">
<HeatMap transactions={transactions} />
{viewMode === 'heatmap' ? (
<HeatMap transactions={animation.visibleTransactions} />
) : (
<FlowMap
arcs={animation.active ? animation.visibleArcs : arcs}
focusCity={focusCity}
onCityClick={setFocusCity}
/>
)}
{/* Period selector — floating over map */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
<PeriodSelector value={periodDays} onChange={setPeriodDays} />
<PeriodSelector
value={periodDays}
onChange={handlePeriodChange}
animationActive={animation.active}
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
/>
</div>
{/* Transaction count + source badge */}
{!loading && (
{/* Transaction count + source badge (masqués en mode animation) */}
{!loading && !animation.active && (
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
@@ -59,11 +136,28 @@ export default function App() {
? 'bg-emerald-950/80 border-emerald-700 text-emerald-400'
: 'bg-[#0a0b0f]/80 border-[#2e2f3a] text-[#4b5563]'
}`}>
{source === 'live' ? '● live Ğ1v2' : '○ mock'}
{source === 'live'
? <>{refreshing ? <span className="animate-spin inline-block"></span> : '●'} live Ğ1v2{lastUpdate && <span className="ml-1 opacity-60">{lastUpdate.toLocaleTimeString('fr-FR')}</span>}</>
: '○ mock'}
</div>
</div>
)}
{/* Animation player */}
{animation.active && (
<AnimationPlayer
frames={animation.frames}
currentIndex={animation.currentIndex}
playing={animation.playing}
speed={animation.speed}
onSeek={animation.seek}
onPlay={animation.play}
onPause={animation.pause}
onSpeedChange={animation.setSpeed}
onClose={animation.deactivate}
/>
)}
{/* Loading overlay */}
{loading && (
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
+109
View File
@@ -0,0 +1,109 @@
import type { TimeFrame } from '../hooks/useAnimation';
interface AnimationPlayerProps {
frames: TimeFrame[];
currentIndex: number;
playing: boolean;
speed: 1 | 2 | 4;
onSeek: (i: number) => void;
onPlay: () => void;
onPause: () => void;
onSpeedChange: (s: 1 | 2 | 4) => void;
onClose: () => void;
}
export function AnimationPlayer({
frames,
currentIndex,
playing,
speed,
onSeek,
onPlay,
onPause,
onSpeedChange,
onClose,
}: AnimationPlayerProps) {
const frame = frames[currentIndex];
return (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
{/* Frame label + position */}
<div className="flex items-center justify-between">
<span className="text-[#d4a843] text-sm font-medium">
{frame?.label ?? '—'}
</span>
<span className="text-[#4b5563] text-xs tabular-nums">
{currentIndex + 1} / {frames.length}
</span>
</div>
{/* Slider */}
<input
type="range"
min={0}
max={frames.length - 1}
value={currentIndex}
onChange={(e) => onSeek(Number(e.target.value))}
className="w-full h-1 accent-[#d4a843] cursor-pointer"
/>
{/* Controls row */}
<div className="flex items-center justify-between">
{/* Playback buttons */}
<div className="flex items-center gap-1">
<button
onClick={() => onSeek(Math.max(0, currentIndex - 1))}
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
title="Frame précédente"
>
</button>
<button
onClick={playing ? onPause : onPlay}
className="px-4 py-1.5 bg-[#d4a843] text-[#0a0b0f] rounded-lg font-bold text-sm hover:bg-[#e8c060] transition-colors min-w-[52px] text-center shadow-[0_0_10px_rgba(212,168,67,0.3)]"
>
{playing ? '⏸' : '▶'}
</button>
<button
onClick={() => onSeek(Math.min(frames.length - 1, currentIndex + 1))}
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
title="Frame suivante"
>
</button>
</div>
{/* Speed selector */}
<div className="flex items-center gap-1">
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
{([1, 2, 4] as const).map((s) => (
<button
key={s}
onClick={() => onSpeedChange(s)}
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
speed === s
? 'bg-[#d4a843] text-[#0a0b0f]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}`}
>
×{s}
</button>
))}
</div>
{/* Close */}
<button
onClick={onClose}
className="text-[#4b5563] hover:text-white transition-colors px-2 py-1 text-sm ml-2"
title="Quitter l'animation"
>
</button>
</div>
</div>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import L from 'leaflet';
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 (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 };
};
const maxVol = Math.max(...corridors.map(c => c.totalVolume), 1);
const maxNodeVol = Math.max(...[...cityNodes.values()].map(c => c.emitted + c.received), 1);
// ---- Arcs ----
const arcElems = corridors.map((c, idx) => {
const p1 = proj(c.fromLat, c.fromLng);
const p2 = proj(c.toLat, c.toLng);
// Point de contrôle bezier quadratique : décalage perpendiculaire au milieu
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;
// Flèche de direction au milieu (t = 0.5) du bezier
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; // tangente normalisée
const px = -ny; const py = nx; // perpendiculaire
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 = c.totalVolume / maxVol;
const strokeW = Math.max(1, 1.5 + Math.log1p(c.totalVolume) * 0.8);
const opacity = 0.35 + 0.55 * ratio;
// Couleur selon focusCity
const isFocusFrom = focusCity && c.fromCity === focusCity;
const isFocusTo = focusCity && c.toCity === focusCity;
const stroke = !focusCity ? `url(#grad${idx})`
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
const arrowFill = !focusCity ? '#e65100'
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
return {
idx, c, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`,
};
});
// ---- Nœuds ----
const nodeElems = [...cityNodes.entries()].map(([name, data]) => {
const p = proj(data.lat, data.lng);
const vol = data.emitted + data.received;
const r = Math.max(3, Math.min(14, 3 + 9 * (vol / maxNodeVol)));
return { name, p, r, isSelected: focusCity === name };
});
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]);
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.c.fromCity}-${a.c.toCity}`} 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 villes (pointer-events activés uniquement ici) */}
<g style={{ pointerEvents: 'all' }}>
{svgElements.nodeElems.map(node => (
<circle
key={node.name}
cx={node.p.x}
cy={node.p.y}
r={node.r}
fill={node.isSelected ? '#ffffff' : '#d4a843'}
stroke={node.isSelected ? '#d4a843' : '#0a0b0f'}
strokeWidth={1.5}
style={{ cursor: 'pointer' }}
onClick={() => onCityClick(focusCity === node.name ? null : node.name)}
/>
))}
</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>
);
}
+101 -4
View File
@@ -1,6 +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 = [
@@ -9,16 +15,40 @@ const PERIODS = [
{ label: '30 jours', days: 30 },
];
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
const PRESET_DAYS = new Set([1, 7, 30]);
export function PeriodSelector({ value, onChange, animationActive, onAnimate, 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 gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
{PERIODS.map(({ label, days }) => (
<button
key={days}
onClick={() => onChange(days)}
onClick={() => { onChange(days); setCustomOpen(false); }}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${value === days
${value === days && !customOpen
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
@@ -27,6 +57,73 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
{label}
</button>
))}
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
{/* Bouton Personnaliser + champ inline */}
{customOpen ? (
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="number"
min={1}
max={365}
value={inputVal}
onChange={(e) => setInputVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setCustomOpen(false);
}}
onBlur={commit}
placeholder="jours"
className="w-16 px-2 py-1 text-sm bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] text-center focus:outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/>
<span className="text-[#6b7280] text-xs">j</span>
</div>
) : (
<button
onClick={openCustom}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${isCustomActive
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
`}
>
{isCustomActive ? `${value} jours` : 'Personnaliser'}
</button>
)}
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
<button
onClick={onAnimate}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${animationActive
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
}
`}
>
Animer
</button>
<div 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>
);
}
+249 -65
View File
@@ -1,29 +1,85 @@
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;
}
const MEDALS = ['🥇', '🥈', '🥉'];
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
function StatCard({ label, value, sub, delta }: { label: string; value: string; sub?: string; delta?: 'up' | 'down' | null }) {
return (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 space-y-1">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">{label}</p>
<p className="text-[#d4a843] text-2xl font-bold tabular-nums">{value}</p>
<p className="text-[#d4a843] text-2xl font-bold tabular-nums">
{value}
{delta === 'up' && <span className="text-emerald-400 text-sm ml-1.5"></span>}
{delta === 'down' && <span className="text-red-400 text-sm ml-1.5"></span>}
</p>
{sub && <p className="text-[#6b7280] text-xs">{sub}</p>}
</div>
);
}
export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) {
function formatDU(g1: number, ud: number): string {
const du = g1 / ud;
if (du < 10) return `${du.toFixed(2)} DU`;
if (du < 100) return `${du.toFixed(1)} DU`;
return `${Math.round(du).toLocaleString('fr-FR')} DU`;
}
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 }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const geoPct = stats && stats.transactionCount > 0
? Math.round((stats.geoCount / stats.transactionCount) * 100)
: null;
const prevStats = useRef<PeriodStats | null>(null);
// Calcule le delta d'une valeur par rapport au refresh précédent
function delta(current: number, prevMap: Map<string, number>, key: string) {
const prev = prevMap.get(key);
if (prev === undefined) return null;
if (current > prev) return <span className="text-emerald-400 text-xs ml-1"></span>;
if (current < prev) return <span className="text-red-400 text-xs ml-1"></span>;
return null;
}
// Construit une map volume précédent par ville
const prevCityVolume = new Map(
(prevStats.current?.topCities ?? []).map((c) => [c.name, c.volume])
);
const prevVolume = prevStats.current?.totalVolume ?? null;
const prevTxCount = prevStats.current?.transactionCount ?? null;
// Mémorise les stats après le rendu
if (stats && !loading) prevStats.current = stats;
return (
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
@@ -33,82 +89,210 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
Ğ
</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>
</div>
{/* Period label */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
Période : <span className="text-[#6b7280]">{periodLabel}</span>
{/* Description */}
<p className="text-[#6b7280] text-xs leading-relaxed border-t border-[#1e1f2a] pt-3">
Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
</p>
{/* 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`}
/>
<StatCard
label="Transactions"
value={stats.transactionCount.toLocaleString('fr-FR')}
sub={`${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`}
/>
{/* 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">
{/* Period label */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
{animationLabel
? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></>
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
}
</p>
{/* ---- 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 items-center gap-3"
>
<span className="text-base">{MEDALS[i]}</span>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{city.name}</p>
<p className="text-[#6b7280] text-xs">{city.count} tx</p>
</div>
<span className="text-[#d4a843] text-sm font-mono shrink-0">
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
</span>
{/* ---- 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">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
{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 */}
<div className="mt-auto pt-4 border-t border-[#1e1f2a]">
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
<p className="text-[#2e2f3a] text-xs text-center">
{source === 'live' ? 'Ğ1v2 · Subsquid + Cesium+' : 'Données simulées · mock'}
</p>
<a
href="https://git.open.us.org/syoul/g1flux"
target="_blank"
rel="noopener noreferrer"
className="block text-[#d4a843] hover:text-[#e8c060] text-xs text-center transition-colors"
>
git.open.us.org/syoul/g1flux
</a>
<p className="text-[#d4a843] text-xs text-center">
Logiciel libre sous licence AGPLv3
</p>
</div>
</aside>
);
+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;
}
+9 -3
View File
@@ -5,6 +5,7 @@ export interface Transaction {
lng: number;
amount: number; // Ğ1 (pas en centimes)
city: string;
countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars
toKey: string;
}
@@ -75,6 +76,7 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
lng,
amount,
city: city.name,
countryCode: 'FR',
fromKey: generateKey(),
toKey: generateKey(),
});
@@ -83,21 +85,25 @@ 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[]) {
const totalVolume = transactions.reduce((sum, tx) => sum + tx.amount, 0);
const transactionCount = transactions.length;
const cityVolumes: Record<string, { volume: number; count: number }> = {};
const cityVolumes: Record<string, { volume: number; count: number; countryCode: string }> = {};
for (const tx of transactions) {
if (!cityVolumes[tx.city]) {
cityVolumes[tx.city] = { volume: 0, count: 0 };
cityVolumes[tx.city] = { volume: 0, count: 0, countryCode: tx.countryCode ?? '' };
}
cityVolumes[tx.city].volume += tx.amount;
cityVolumes[tx.city].count += 1;
+131
View File
@@ -0,0 +1,131 @@
import { useState, useMemo, useEffect } from 'react';
import type { Transaction } from '../data/mockData';
import type { TransactionArc } from '../data/arcData';
export interface TimeFrame {
label: string;
from: number; // Unix ms
to: number; // Unix ms
}
function buildFrames(periodDays: number): TimeFrame[] {
const now = Date.now();
const start = now - periodDays * 24 * 60 * 60 * 1000;
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
new Date(ms).toLocaleDateString('fr-FR', opts);
if (periodDays === 1) {
return Array.from({ length: 24 }, (_, i) => {
const from = start + i * 3_600_000;
const to = from + 3_600_000;
const h = new Date(from).getHours();
return {
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h ${h + 1}h`,
from,
to,
};
});
}
if (periodDays === 7) {
return Array.from({ length: 7 }, (_, i) => {
const from = start + i * 86_400_000;
const to = from + 86_400_000;
return {
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
from,
to,
};
});
}
// 30 days → half-week frames (3.5 days ≈ 910 frames)
const HALF_WEEK = 3.5 * 86_400_000;
const frames: TimeFrame[] = [];
let cursor = start;
while (cursor < now) {
const from = cursor;
const to = Math.min(cursor + HALF_WEEK, now);
frames.push({
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
from,
to,
});
cursor = to;
}
return frames;
}
export function useAnimation(transactions: Transaction[], 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>(2);
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
// Reset cursor when period or activation changes.
// Stop playback only on deactivation — not on activation, so activate() can
// start playing immediately without being overridden by this effect.
useEffect(() => {
setCurrentIndex(0);
if (!active) setPlaying(false);
}, [periodDays, active]);
// Auto-advance: one step every (2000 / speed) ms
useEffect(() => {
if (!playing || !active) return;
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
const t = setTimeout(() => {
setCurrentIndex((i) => {
if (i >= frames.length - 1) {
setPlaying(false);
return i;
}
return i + 1;
});
}, delay);
return () => clearTimeout(t);
}, [playing, active, currentIndex, speed, frames.length]);
const visibleTransactions = useMemo(() => {
if (!active || frames.length === 0) return transactions;
const frame = frames[currentIndex];
if (!frame) return transactions;
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
}, [active, transactions, frames, currentIndex]);
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); setSpeed(1); setPlaying(true); },
deactivate: () => { setActive(false); },
playing,
play: () => setPlaying(true),
pause: () => setPlaying(false),
currentIndex,
seek: (i: number) => { setCurrentIndex(i); setPlaying(false); },
speed,
setSpeed,
frames,
currentFrame: frames[currentIndex] ?? null,
visibleTransactions,
visibleArcs,
frameTotalCount,
};
}
+93 -36
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,14 +47,17 @@ 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 rawTransfers = 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 totalCount = rawTransfers.length;
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
// Carte SS58 courant → clé Duniter (= _id Cesium+)
@@ -51,43 +68,70 @@ 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 }>();
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 });
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode });
}
} catch (err) {
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,
amount: t.amount,
city: geo.city,
fromKey: t.fromId,
toKey: t.toId,
id: t.id,
timestamp: t.timestamp,
lat: fromGeo.lat,
lng: fromGeo.lng,
amount: t.amount,
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) };
}
// ---------------------------------------------------------------------------
@@ -97,13 +141,16 @@ export interface PeriodStats {
totalVolume: number;
transactionCount: number; // total blockchain (y compris non-géolocalisés)
geoCount: number; // transactions visibles sur la carte
topCities: { name: string; volume: number; count: number }[];
topCities: { name: string; volume: number; count: number; countryCode: string }[];
}
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,
};
}
+75 -14
View File
@@ -16,18 +16,75 @@ import { z } from 'zod';
export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro';
export interface GeoProfile {
name: string; // nom d'identité Ğ1 (ex: "Anikka")
city: string;
lat: number;
lng: number;
name: string; // nom d'identité Ğ1 (ex: "Anikka")
city: string;
countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
lat: number;
lng: number;
}
// Détection de pays par bounding box (pays présents dans la communauté Ğ1)
const COUNTRY_BOXES: { code: string; latMin: number; latMax: number; lngMin: number; lngMax: number }[] = [
// Petits pays d'abord : leurs bounding boxes chevauchent celle de la France
{ code: 'LU', latMin: 49.4, latMax: 50.2, lngMin: 5.7, lngMax: 6.5 },
{ code: 'BE', latMin: 49.5, latMax: 51.5, lngMin: 2.5, lngMax: 6.4 },
{ code: 'CH', latMin: 45.8, latMax: 47.8, lngMin: 5.9, lngMax: 10.5 },
{ code: 'NL', latMin: 50.7, latMax: 53.6, lngMin: 3.3, lngMax: 7.2 },
{ code: 'DE', latMin: 47.3, latMax: 55.1, lngMin: 6.0, lngMax: 15.0 },
{ code: 'FR', latMin: 41.3, latMax: 51.1, lngMin: -5.1, lngMax: 9.6 },
{ code: 'ES', latMin: 35.9, latMax: 43.8, lngMin: -9.3, lngMax: 4.3 },
{ code: 'PT', latMin: 36.8, latMax: 42.2, lngMin: -9.5, lngMax: -6.2 },
{ code: 'IT', latMin: 36.6, latMax: 47.1, lngMin: 6.6, lngMax: 18.5 },
{ code: 'GB', latMin: 49.9, latMax: 60.9, lngMin: -8.2, lngMax: 1.8 },
{ code: 'MA', latMin: 27.6, latMax: 35.9, lngMin: -13.2, lngMax: -1.0 },
{ code: 'TN', latMin: 30.2, latMax: 37.5, lngMin: 7.5, lngMax: 11.6 },
{ code: 'SN', latMin: 12.3, latMax: 16.7, lngMin: -17.5, lngMax: -11.4 },
{ code: 'CA', latMin: 41.7, latMax: 83.1, lngMin: -141.0,lngMax: -52.6 },
{ code: 'BR', latMin: -33.7, latMax: 5.3, lngMin: -73.9, lngMax: -34.8 },
];
function latLngToCountryCode(lat: number, lng: number): string {
// France métropolitaine en premier (cas le plus fréquent)
for (const b of COUNTRY_BOXES) {
if (lat >= b.latMin && lat <= b.latMax && lng >= b.lngMin && lng <= b.lngMax) return b.code;
}
return '';
}
/** Nettoie le nom de ville Cesium+ : retire le code postal ("Paris, 75001" → "Paris") */
export function cleanCityName(city: string): string {
return city.split(',')[0].trim();
}
// Noms de pays en français/anglais → code ISO (Cesium+ utilise le français)
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
'france': 'FR', 'belgique': 'BE', 'belgium': 'BE',
'suisse': 'CH', 'switzerland': 'CH', 'schweiz': 'CH',
'luxembourg': 'LU', 'allemagne': 'DE', 'germany': 'DE',
'espagne': 'ES', 'spain': 'ES', 'portugal': 'PT',
'italie': 'IT', 'italy': 'IT', 'pays-bas': 'NL',
'netherlands': 'NL', 'royaume-uni': 'GB', 'united kingdom': 'GB',
'maroc': 'MA', 'morocco': 'MA', 'tunisie': 'TN', 'tunisia': 'TN',
'sénégal': 'SN', 'senegal': 'SN', 'canada': 'CA', 'brésil': 'BR', 'brazil': 'BR',
};
/** Extrait le pays depuis le champ city Cesium+ (ex: "Heusy, 4800, Belgique" → "BE") */
function countryCodeFromCity(city: string): string {
const parts = city.split(',');
for (let i = parts.length - 1; i >= 0; i--) {
const token = parts[i].trim().toLowerCase();
const code = COUNTRY_NAME_TO_CODE[token];
if (code) return code;
}
return '';
}
// geoPoint accepte n'importe quel type — Cesium+ utilise plusieurs formats ES geo_point
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(),
}),
});
@@ -94,11 +151,13 @@ export async function resolveGeoByKeys(
const src = hit._source;
const geo = parseGeoPoint(src.geoPoint);
if (!geo) continue;
const city = src.city ?? 'Inconnue';
result.set(hit._id, {
name: src.title ?? '',
city: src.city ?? 'Inconnue',
lat: geo.lat,
lng: geo.lng,
name: src.title ?? '',
city,
countryCode: countryCodeFromCity(city) || latLngToCountryCode(geo.lat, geo.lng),
lat: geo.lat,
lng: geo.lng,
});
}
return result;
@@ -152,11 +211,13 @@ export async function resolveGeoByNames(
const src = hit._source;
const geo = parseGeoPoint(src.geoPoint);
if (geo && src.title) {
const city = src.city ?? 'Inconnue';
result.set(src.title.toLowerCase(), {
name: src.title,
city: src.city ?? 'Inconnue',
lat: geo.lat,
lng: geo.lng,
name: src.title,
city,
countryCode: countryCodeFromCity(city) || latLngToCountryCode(geo.lat, geo.lng),
lat: geo.lat,
lng: geo.lng,
});
}
}
+41 -10
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(),
@@ -33,6 +33,7 @@ const SubsquidTransferNodeSchema = z.object({
const SubsquidResponseSchema = z.object({
data: z.object({
transfers: z.object({
totalCount: z.number().int(),
nodes: z.array(SubsquidTransferNodeSchema),
}),
}),
@@ -62,6 +63,7 @@ const TRANSFERS_QUERY = `
first: $limit
filter: { timestamp: { greaterThanOrEqualTo: $since } }
) {
totalCount
nodes {
id
blockNumber
@@ -151,10 +153,36 @@ 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;
}
export async function fetchTransfers(
periodDays: number,
limit = 2000
): Promise<RawTransfer[]> {
): Promise<FetchTransfersResult> {
const since = new Date(
Date.now() - periodDays * 24 * 60 * 60 * 1000
).toISOString();
@@ -180,12 +208,15 @@ export async function fetchTransfers(
const parsed = SubsquidResponseSchema.parse(raw);
return parsed.data.transfers.nodes.map((node) => ({
id: node.id,
timestamp: new Date(node.timestamp).getTime(),
amount: parseInt(node.amount, 10) / 100,
fromId: node.fromId ?? '',
toId: node.toId ?? '',
fromName: node.from?.linkedIdentity?.name ?? '',
}));
return {
totalCount: parsed.data.transfers.totalCount,
transfers: parsed.data.transfers.nodes.map((node) => ({
id: node.id,
timestamp: new Date(node.timestamp).getTime(),
amount: parseInt(node.amount, 10) / 100,
fromId: node.fromId ?? '',
toId: node.toId ?? '',
fromName: node.from?.linkedIdentity?.name ?? '',
})),
};
}
+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(),