16 Commits

Author SHA1 Message Date
Syoul f81ff92e0e Merge pull request 'dev' (#3) from dev into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #3
2026-04-22 02:10:38 +02:00
syoul dfe832728e fix: pointer-events="stroke" sur les arcs — corrige tooltip persistant dans la zone de fill
ci/woodpecker/push/woodpecker Pipeline was successful
Le <g> parent héritait pointer-events:all aux <path> enfants, capturant
la zone de fill géométrique (intérieur de l'arc). onMouseLeave ne se
déclenchait pas en restant dans cette zone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 02:06:44 +02:00
syoul 782b063b25 fix: zone de hit des arcs réduite au strokeWidth visible (sans marge)
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:57:43 +02:00
syoul 88e2232cfb feat: découverte automatique des nœuds squid via duniter_peerings
ci/woodpecker/push/woodpecker Pipeline was successful
- PeerDiscovery.ts : appel duniter_peerings sur rpc.duniter.org, extraction
  des endpoints squid, normalisation URLs, cache localStorage 24h
- EndpointPopover : section "Réseau Ğ1" avec nœuds découverts auto-testés
  à l'ouverture, bouton actualiser pour forcer un refresh du cache
- FlowMap : zone de hit des arcs réduite (max 12→4 px) pour ne plus
  interférer avec le zoom/déplacement de la carte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:28:46 +02:00
Syoul a36a6729e3 Merge pull request 'dev' (#2) from dev into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #2
2026-04-22 00:29:50 +02:00
syoul 6b42a75140 perf: réduire la fréquence de polling — data 2 min, statut 60s
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:02:56 +02:00
syoul 8e396cd331 feat: nature des échanges — catégorisation et détail des commentaires de transactions
ci/woodpecker/push/woodpecker Pipeline was successful
- Nouveau commentParser.ts : ~80 règles regex multilingues, 11 catégories
- SubsquidAdapter : fetch du champ comment.remark depuis SubSquid
- Transaction et TransactionArc : champs comment et category
- StatsPanel : section Nature des échanges avec barres cliquables (détail inline)
- FlowMap : tooltip au survol des arcs avec répartition catégories + commentaires
- InfoPanel mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:29:59 +02:00
Syoul 96ee4a2382 Merge pull request 'dev' (#1) from dev into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2026-04-21 20:52:40 +02:00
syoul 6b7591db32 docs: ajouter dossier releases/ avec notes de version
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:47:55 +02:00
syoul 0d9415ae6a feat: indicateurs de statut et configuration des endpoints SubSquid/Cesium+
ci/woodpecker/push/woodpecker Pipeline was successful
- Dots de statut en temps réel dans le StatsPanel (ok/slow/error + latence)
- Bannière d'alerte si un service est inaccessible
- EndpointPopover : sélection parmi nœuds connus, test de latence live, URL custom
- Rechargement automatique des données après changement d'endpoint
- SubsquidAdapter et CesiumAdapter lisent l'URL active depuis EndpointConfig
- InfoPanel mis à jour (overlay DU + statut des services)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:43:33 +02:00
syoul 7c9d626b98 feat: vue dividende universel — overlay membres actifs géolocalisés
ci/woodpecker/push/woodpecker Pipeline was successful
Bouton DU (gauche carte) : affiche en overlay des cercles verts
proportionnels au nombre de membres WoT actifs géolocalisés par ville.
Chargement à la demande, mis en cache 1h.

Pipeline :
  SubsquidAdapter.fetchActiveMemberKeys() → isMember:true (~7000)
  CesiumAdapter.resolveGeoByKeysBatched() → lots de 500 clés
  DataService.fetchMemberCities()         → agrégation + cache 1h
  HeatMap → CircleMarkers Leaflet en overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:57:19 +01:00
syoul 0136ff9ce1 docs: mettre à jour InfoPanel avec les nouvelles features
ci/woodpecker/push/woodpecker Pipeline was successful
Raccourcis clavier, recherche identité (⌕), URL partageable et
sparkline documentés dans le panneau d'aide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:32:53 +01:00
syoul 575ca7a1fc feat: raccourcis clavier, URL partageable, sparkline, recherche identité
ci/woodpecker/push/woodpecker Pipeline was successful
- Raccourcis clavier : ←/→ (frames), Espace (play/pause), Échap
  (quitter animation/fermer info), H (basculer heatmap↔flux)
- URL partageable : ?period=7&view=flow&city=Paris — état restauré
  au chargement et mis à jour sans rechargement (history.replaceState)
- Sparkline : mini bar-chart SVG dans le StatsPanel montrant l'activité
  sur la période (données déjà en mémoire, aucune requête)
- Recherche identité : champ flottant (⌕) acceptant un nom Ğ1 ou une
  clé g1…, résout via Subsquid + Cesium+, bascule en vue flux et
  met la ville en focus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:28:58 +01:00
syoul ac168c3689 Merge branch 'dev'
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-28 11:52:27 +01:00
syoul 8f9a11c4e8 chore: bump version to 1.4.1
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:50:31 +01:00
syoul 63f50d5762 feat: géolocaliser les comptes non-membres via Cesium+
ci/woodpecker/push/woodpecker Pipeline was successful
Pour les fromId/toId absents du keyMap WoT, applique ss58ToDuniterKey
directement pour tenter un lookup Cesium+. Les non-membres ayant un
profil géolocalisé (ex: comptes portefeuille avec ville renseignée)
apparaissent désormais dans le flux animé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:38:31 +01:00
23 changed files with 1788 additions and 57 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "g1flux", "name": "g1flux",
"private": true, "private": true,
"version": "1.4.0", "version": "1.4.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+22
View File
@@ -0,0 +1,22 @@
## What's Changed
### Nouvelles fonctionnalités
- Indicateurs de statut en temps réel pour SubSquid et Cesium+ dans le panneau latéral (vert / jaune / rouge + latence)
- Bannière d'alerte automatique si un service devient inaccessible
- Configuration des endpoints à chaud : choisir parmi les nœuds publics connus, tester la latence en live, ou saisir une URL personnalisée — sans rechargement de page
- Persistance de la configuration dans `localStorage`
### Améliorations
- InfoPanel mis à jour : ajout des sections *Overlay Dividende Universel* et *Statut des services*
### Détails techniques
- Nouveau `src/services/EndpointConfig.ts` — gestion des URLs actives et des nœuds connus
- Nouveau `src/hooks/useServiceStatus.ts` — ping parallèle toutes les 30 s avec timeout 8 s via `AbortController`
- Nouveau `src/components/ServiceStatusDots.tsx` et `EndpointPopover.tsx`
- `SubsquidAdapter` et `CesiumAdapter` lisent l'URL active à chaque appel (plus de constante figée à l'import)
- Aucune nouvelle dépendance npm
**Full Changelog**: https://git.syoul.fr/geoflux/compare/v1.4.1...v1.5.0
+24
View File
@@ -0,0 +1,24 @@
## What's Changed
### Nouvelles fonctionnalités
- **Nature des échanges** — les commentaires de transactions sont analysés et classés automatiquement en catégories : don & gratitude, alimentation, soin & bien-être, vêtements, culture & loisirs, événement, service & travaux, remboursement, migration, ticket, autre
- Distribution des catégories affichée dans le panneau latéral (barres proportionnelles sur les transactions commentées de la période) — chaque catégorie est cliquable pour dérouler la liste des transactions avec leur commentaire et montant
- Tooltip au survol des arcs en vue Flux : répartition des catégories + échantillon de commentaires bruts du corridor
- 76 % des transactions Ğ1v2 comportent un commentaire — le champ `remark` est désormais fetché depuis SubSquid
### Améliorations
- InfoPanel mis à jour : section *Nature des échanges* documentée
### Détails techniques
- Nouveau `src/data/commentParser.ts` — ~80 règles regex multilingues (FR/ES/CA/IT/EN/PT), 11 catégories, priorité ordonnée
- `SubsquidAdapter` : ajout de `comment { remark }` à la query GraphQL
- `Transaction` et `TransactionArc` : nouveaux champs `comment: string | null` et `category: TxCategory`
- `Corridor` : nouveaux champs `categories` (agrégées) et `comments` (échantillon jusqu'à 5)
- `PeriodStats` : nouveaux champs `categoryBreakdown` et `commentedCount`
- Zone de hit des arcs SVG élargie (+8 px) pour faciliter le survol
- Aucune nouvelle dépendance npm
**Full Changelog**: https://git.syoul.fr/geoflux/compare/v1.5.0...v1.6.0
+118
View File
@@ -0,0 +1,118 @@
:sparkles: **GéoFlux v2.0.0 — "Superposition"**
> **Release du 1er avril 2026** · branche `main` · tag `v2.0.0`
> Nécessite Node 20+.
---
Bonjour à toutes et tous,
Nous sommes heureux d'annoncer la sortie de **GéoFlux v2.0.0**, la plus grande mise à jour depuis la v1.0.0, et probablement depuis l'invention de la monnaie libre. Cette version introduit la navigation historique depuis la genèse de la Ğ1, la géolocalisation quantique relative, la prise en charge des comptes non localisés par activation quantique passive, et l'intégration des transactions par chant cantique (RFC 4871-bis).
---
:new: **Nouvelles fonctionnalités**
---
:atom_symbol: **Géolocalisation quantique relative (GQR)**
Suite à plusieurs signalements d'utilisateurs affirmant que les coordonnées GPS fournies par Cesium+ étaient *"imposées par les satellites gouvernementaux"*, GéoFlux propose désormais un mode de géolocalisation alternatif basé sur la position quantique de chaque nœud dans le graphe de transactions.
Le principe : si Alice envoie régulièrement des Ğ1 à Bob, et que Bob est à Bordeaux, alors Alice est probablement à Bordeaux aussi — ou dans un état de superposition jusqu'à ce qu'on clique dessus. Précision déclarée : ±40 km. Précision observée : ±40 km dans 34% des cas, *"quelque part en France"* dans les 66% restants, ce qui reste supérieur à rien.
Activable dans **Paramètres → Confidentialité → *"Je préfère ne pas confier mes coordonnées à l'État quantique mondial"***.
Désactivé par défaut, parce que nous, personnellement, faisons confiance aux satellites.
---
:ghost: **Activation quantique passive (AQP) des comptes non localisés**
Les comptes sans profil Cesium+ représentaient **41% du graphe** et apparaissaient comme des nœuds gris sans position. C'est techniquement correct mais visuellement déprimant.
Leur position est désormais inférée par triangulation stochastique à partir de leurs cinq plus proches voisins transactionnels. L'algorithme converge en deux itérations. Quand il ne converge pas, le compte est placé à **Montpellier**, car c'est là qu'il y a le plus de membres Ğ1, et aussi parce qu'il faut bien choisir quelque chose.
Les nœuds AQP s'affichent avec un **contour pointillé** indiquant qu'ils sont *"quantiquement probables"* et non *"classiquement certains"*. La distinction philosophique est réelle. La distinction visuelle est subtile.
---
:musical_note: **Transactions par chant cantique (PTCC — RFC 4871-bis)**
Le RFC 4871-bis, adopté lors du dernier HackMeet de Cluny, définit un mécanisme d'émission de transaction par modulation vocale : un chant grégorien de **trois strophes minimum**, entonné à portée Bluetooth d'un autre membre, déclenche un envoi de **1 Ğ1** signé par l'empreinte spectrale du chanteur.
GéoFlux v2.0.0 indexe et affiche ces transactions : arcs de couleur **ambre**, icône ♩. Le StatsPanel expose le *"Flux vocal agrégé (Ğ1/strophe)"*.
> **État du réseau à ce jour :** 14 transactions cantiques détectées depuis la genèse. 12 proviennent d'un seul nœud, situé à l'abbaye de Sénanque. Ce nœud n'a pas de profil Cesium+ et a été placé à **Cavaillon** par AQP, ce qui est raisonnablement juste.
:warning: Une transaction émise en mode mineur était interprétée comme un remboursement (bug #127). Résolu. Le mode mineur est désormais traité comme le mode majeur sur le plan comptable, sans préjuger de son contenu émotionnel.
---
:clock3: **Navigation historique depuis la genèse (Phase 13)**
- Sélecteur de **plage libre** (`from` / `to`) en remplacement du menu "derniers N jours"
- **Granularité automatique** selon la durée (jour → semaine → mois → trimestre)
- **Timeline interactive** couvrant mars 2017 → aujourd'hui, avec volumes trimestriels en arrière-plan
- Affichage en **cercles proportionnels** pour les fenêtres > 30 jours
- **Cache IndexedDB** — périodes passées mises en cache 24 h. Pour les transactions cantiques, la tonalité dominante de chaque strophe est également stockée. Cette information n'est utilisée nulle part mais semblait dommage de jeter.
La timeline révèle clairement qu'il ne s'est presque rien passé en 2020. Nous ne faisons aucun commentaire.
---
:wrench: **Corrections de bugs**
- **#88** — Timeout systématique sur les périodes > 180 jours. Résolu par agrégation côté client.
- **#91** — Crash mémoire Firefox 124 sur > 8 000 transactions. Résolu. Firefox 124 n'est plus supporté non plus, mais c'est une coïncidence.
- **#103** — Le sélecteur de période ne revenait pas à sa valeur après annulation. Ce bug existait depuis la v0.9.0 ; personne ne l'avait signalé, ce qui nous en dit long sur l'utilisation du bouton Annuler.
- **#107** — Arcs invisibles au zoom 3 sur Safari mobile. Résolu. Safari n'a pas été remercié pour sa coopération.
- **#112** — Double `flyTo` en cas de clic rapide sur une ville. Résolu par un debounce de 200 ms. L'app ignore désormais délibérément vos clics pendant 200 ms. C'est de l'UX.
- **#124** — Les comptes placés à Montpellier *avant* l'AQP y restaient après convergence. Résolu. Montpellier reste néanmoins le repli.
- **#127** — Transaction cantique en mode mineur interprétée comme un remboursement. Résolu sur le plan comptable, pas sur le plan musical.
---
:warning: **Breaking changes**
[details="Voir les breaking changes"]
**`useGeoFlux()`** ne retourne plus `periodDays: number` mais `period: { from: Date; to: Date; granularity: Granularity }`.
**`DataService.fetchData(periodDays)`** → **`fetchData(from: Date, to: Date)`**. L'ancienne signature lève une erreur TypeScript. TypeScript a, comme toujours, raison.
**`GeoNode`** inclut un nouveau champ `localizationMode: 'classic' | 'quantum' | 'canticle'`. La valeur `'canticle'` s'applique aux nœuds dont la seule source de localisation connue est une transaction cantique reçue. Il y en a deux.
**`localStorage['geoflux-cache']`** est supprimé au démarrage. Les données mises en cache avant la v2.0.0 ne sont pas migrées, pour des raisons techniques tout à fait valables que nous n'exposerons pas ici.
[/details]
---
:no_entry: **Ce qui n'est pas dans cette version**
- **Transactions par tambour chamanique** (RFC 4871-ter) — encore en draft, en relecture à Tübingen depuis dix-huit mois
- **Regroupement par pays au zoom ≤ 4** — `countryCode` vaut `null` pour 34% des nœuds, ce qui aurait fait de `"null"` le cinquième pays par volume de transactions Ğ1
- **Export CSV** — décalé en v2.1, comme en v1.9, v1.7, et v1.5
- **Mode sombre**
- **Un logo**
---
:bar_chart: **Statistiques**
```
Commits depuis v1.4.1 : 47
Lignes ajoutées / supprimées : 1847 / 412
Transactions cantiques (genèse) : 14
Nœuds à Montpellier avant AQP : 38
Nœuds à Montpellier après AQP : 12
Nœuds réellement à Montpellier : ? (non déterminable avec certitude)
Durée estimée : 3 semaines
Durée réelle : 11 semaines
```
6 de ces 11 semaines ont été consacrées à la GQR — fonctionnalité qui était initialement **une blague dans un ticket GitHub** et qui s'est retrouvée en production par un enchaînement d'événements que personne ne sait exactement reconstituer.
---
*L'équipe GéoFlux*
+88 -8
View File
@@ -4,8 +4,9 @@ import { PeriodSelector } from './components/PeriodSelector';
import { HeatMap } from './components/HeatMap'; import { HeatMap } from './components/HeatMap';
import { FlowMap } from './components/FlowMap'; import { FlowMap } from './components/FlowMap';
import { AnimationPlayer } from './components/AnimationPlayer'; import { AnimationPlayer } from './components/AnimationPlayer';
import { fetchData } from './services/DataService'; import { SearchBar } from './components/SearchBar';
import type { PeriodStats } from './services/DataService'; import { fetchData, fetchMemberCities } from './services/DataService';
import type { PeriodStats, MemberCity } from './services/DataService';
import type { Transaction } from './data/mockData'; import type { Transaction } from './data/mockData';
import type { TransactionArc } from './data/arcData'; import type { TransactionArc } from './data/arcData';
import { computeStats } from './data/mockData'; import { computeStats } from './data/mockData';
@@ -13,9 +14,10 @@ import { computeFlowStats } from './data/arcData';
import { useAnimation } from './hooks/useAnimation'; import { useAnimation } from './hooks/useAnimation';
import { useMediaQuery } from './hooks/useMediaQuery'; import { useMediaQuery } from './hooks/useMediaQuery';
import { InfoPanel } from './components/InfoPanel'; import { InfoPanel } from './components/InfoPanel';
import { initialUrlState, useUrlSync } from './hooks/useUrlState';
export default function App() { export default function App() {
const [periodDays, setPeriodDays] = useState(7); const [periodDays, setPeriodDays] = useState(initialUrlState.period);
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [arcs, setArcs] = useState<TransactionArc[]>([]); const [arcs, setArcs] = useState<TransactionArc[]>([]);
const [stats, setStats] = useState<PeriodStats | null>(null); const [stats, setStats] = useState<PeriodStats | null>(null);
@@ -25,14 +27,36 @@ export default function App() {
const [source, setSource] = useState<'live' | 'mock'>('mock'); const [source, setSource] = useState<'live' | 'mock'>('mock');
const [currentUD, setCurrentUD] = useState<number>(11.78); const [currentUD, setCurrentUD] = useState<number>(11.78);
const [allTimestamps, setAllTimestamps] = useState<number[]>([]); const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap'); const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>(initialUrlState.view);
const [focusCity, setFocusCity] = useState<string | null>(null); const [focusCity, setFocusCity] = useState<string | null>(initialUrlState.city);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false);
const [showMembers, setShowMembers] = useState(false);
const [memberCities, setMemberCities] = useState<MemberCity[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const [endpointVersion, setEndpointVersion] = useState(0);
const isMobile = useMediaQuery('(max-width: 639px)'); const isMobile = useMediaQuery('(max-width: 639px)');
const toggleMembers = async () => {
if (showMembers) { setShowMembers(false); return; }
if (memberCities.length > 0) { setShowMembers(true); return; }
setMembersLoading(true);
try {
const cities = await fetchMemberCities();
setMemberCities(cities);
setShowMembers(true);
} catch (err) {
console.warn('fetchMemberCities error:', err);
} finally {
setMembersLoading(false);
}
};
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps); const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
// Synchronise l'état dans l'URL (deep link / partage)
useUrlSync(periodDays, viewMode, focusCity);
const handlePeriodChange = (days: number) => { const handlePeriodChange = (days: number) => {
animation.deactivate(); animation.deactivate();
setPeriodDays(days); setPeriodDays(days);
@@ -43,6 +67,31 @@ export default function App() {
setFocusCity(null); setFocusCity(null);
}; };
// Raccourcis clavier
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft' && animation.active) {
animation.seek(Math.max(0, animation.currentIndex - 1));
e.preventDefault();
} else if (e.key === 'ArrowRight' && animation.active) {
animation.seek(Math.min(animation.frames.length - 1, animation.currentIndex + 1));
e.preventDefault();
} else if (e.key === ' ' && animation.active) {
animation.playing ? animation.pause() : animation.play();
e.preventDefault();
} else if (e.key === 'Escape') {
if (infoOpen) { setInfoOpen(false); e.preventDefault(); }
else if (animation.active) { animation.deactivate(); e.preventDefault(); }
} else if (e.key === 'h' || e.key === 'H') {
handleViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap');
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animation.active, animation.playing, animation.currentIndex, animation.frames.length, infoOpen, viewMode]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -68,10 +117,10 @@ export default function App() {
}; };
load(true); load(true);
const interval = setInterval(() => load(false), 30_000); const interval = setInterval(() => load(false), 120_000);
return () => { cancelled = true; clearInterval(interval); }; return () => { cancelled = true; clearInterval(interval); };
}, [periodDays]); }, [periodDays, endpointVersion]);
// Stats heatmap sur la fenêtre courante en mode animation // Stats heatmap sur la fenêtre courante en mode animation
const visibleStats: PeriodStats | null = animation.active const visibleStats: PeriodStats | null = animation.active
@@ -101,6 +150,9 @@ export default function App() {
viewMode, viewMode,
flowStats, flowStats,
focusCity, focusCity,
allTimestamps,
onEndpointChange: () => setEndpointVersion((v) => v + 1),
transactions,
}; };
return ( return (
@@ -111,7 +163,10 @@ export default function App() {
{/* Map area */} {/* Map area */}
<div className="relative flex-1 min-w-0"> <div className="relative flex-1 min-w-0">
{viewMode === 'heatmap' ? ( {viewMode === 'heatmap' ? (
<HeatMap transactions={animation.visibleTransactions} /> <HeatMap
transactions={animation.visibleTransactions}
memberCities={showMembers ? memberCities : []}
/>
) : ( ) : (
<FlowMap <FlowMap
arcs={animation.active ? animation.visibleArcs : arcs} arcs={animation.active ? animation.visibleArcs : arcs}
@@ -140,6 +195,31 @@ export default function App() {
</button> </button>
{/* Barre de recherche identité */}
<div className={`absolute ${isMobile ? 'top-28' : 'top-16'} left-4 z-[1001]`}>
<SearchBar
onResult={(city) => {
setViewMode('flow');
setFocusCity(city);
}}
/>
</div>
{/* Toggle overlay membres DU */}
<button
onClick={toggleMembers}
disabled={membersLoading}
title={showMembers ? 'Masquer les membres' : 'Afficher les membres Ğ1 actifs géolocalisés'}
className={`absolute ${isMobile ? 'top-40' : 'top-28'} left-4 z-[1001] w-10 h-10 backdrop-blur-sm border rounded-xl flex items-center justify-center text-sm transition-colors
${showMembers
? 'bg-[#00c853]/20 border-[#00c853]/60 text-[#00c853]'
: 'bg-[#0a0b0f]/90 border-[#2e2f3a] text-[#6b7280] hover:text-[#00c853]'
}`}
aria-label="Membres DU"
>
{membersLoading ? <span className="animate-spin inline-block text-xs"></span> : 'DU'}
</button>
{/* Period selector — floating over map */} {/* Period selector — floating over map */}
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}> <div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
<PeriodSelector <PeriodSelector
+224
View File
@@ -0,0 +1,224 @@
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
KNOWN_SUBSQUID_NODES,
KNOWN_CESIUM_NODES,
getSubsquidUrl,
getCesiumUrl,
setSubsquidUrl,
setCesiumUrl,
} from '../services/EndpointConfig';
import { discoverSquidNodes, clearPeerCache } from '../services/PeerDiscovery';
import { testEndpoint } from '../hooks/useServiceStatus';
interface Props {
service: 'subsquid' | 'cesium';
onClose: () => void;
onSaved: () => void;
}
interface TestResult {
url: string;
state: 'testing' | 'ok' | 'slow' | 'error';
latencyMs: number | null;
}
const LABELS = { subsquid: 'SubSquid', cesium: 'Cesium+' };
export function EndpointPopover({ service, onClose, onSaved }: Props) {
const currentUrl = service === 'subsquid' ? getSubsquidUrl() : getCesiumUrl();
const knownNodes = service === 'subsquid' ? KNOWN_SUBSQUID_NODES : KNOWN_CESIUM_NODES;
const [inputUrl, setInputUrl] = useState(currentUrl);
const [testResults, setTestResults] = useState<Map<string, TestResult>>(new Map());
const [discoveredUrls, setDiscoveredUrls] = useState<string[]>([]);
const [discovering, setDiscovering] = useState(false);
const [discoverVersion, setDiscoverVersion] = useState(0);
const testUrl = async (url: string) => {
setTestResults((prev) => new Map(prev).set(url, { url, state: 'testing', latencyMs: null }));
try {
const ms = await testEndpoint(service, url);
setTestResults((prev) =>
new Map(prev).set(url, { url, state: ms < 2000 ? 'ok' : 'slow', latencyMs: ms })
);
} catch {
setTestResults((prev) => new Map(prev).set(url, { url, state: 'error', latencyMs: null }));
}
};
// Découverte des nœuds réseau (SubSquid uniquement)
useEffect(() => {
if (service !== 'subsquid') return;
setDiscovering(true);
setDiscoveredUrls([]);
discoverSquidNodes().then((urls) => {
setDiscoveredUrls(urls);
setDiscovering(false);
urls.forEach((url) => testUrl(url));
});
}, [discoverVersion]); // eslint-disable-line react-hooks/exhaustive-deps
const refreshDiscovery = () => {
clearPeerCache();
setDiscoverVersion((v) => v + 1);
};
const handleSave = () => {
const trimmed = inputUrl.trim();
if (!trimmed) return;
if (service === 'subsquid') setSubsquidUrl(trimmed);
else setCesiumUrl(trimmed);
onSaved();
onClose();
};
const dot = (state: TestResult['state']) => {
if (state === 'testing') return <span className="text-[#6b7280] animate-pulse"></span>;
if (state === 'ok') return <span className="text-emerald-400"></span>;
if (state === 'slow') return <span className="text-amber-400"></span>;
return <span className="text-red-500"></span>;
};
const NodeRow = ({ url, label }: { url: string; label?: string }) => {
const result = testResults.get(url);
const isActive = inputUrl === url;
const hostname = label ?? (() => { try { return new URL(url).hostname; } catch { return url; } })();
return (
<div
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
isActive
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
}`}
onClick={() => setInputUrl(url)}
>
<div className="min-w-0">
<p className="text-white text-sm font-medium truncate">{hostname}</p>
<p className="text-[#4b5563] text-xs font-mono truncate">{url}</p>
</div>
<div className="flex items-center gap-2 ml-3 shrink-0">
{result && (
<span className="text-xs font-mono text-[#6b7280]">
{dot(result.state)}
{result.latencyMs !== null && ` ${result.latencyMs} ms`}
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); testUrl(url); }}
className="text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
>
Tester
</button>
</div>
</div>
);
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 space-y-5 max-h-[85vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-white font-bold text-base">
Configurer {LABELS[service]}
</h2>
<button onClick={onClose} className="text-[#4b5563] hover:text-white transition-colors text-xl leading-none">×</button>
</div>
{/* Nœuds connus (statiques) */}
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nœuds connus</p>
{knownNodes.map((node) => (
<NodeRow key={node.url} url={node.url} label={node.label} />
))}
</div>
{/* Nœuds découverts via duniter_peerings (SubSquid uniquement) */}
{service === 'subsquid' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Réseau Ğ1</p>
<button
onClick={refreshDiscovery}
disabled={discovering}
className="text-xs text-[#4b5563] hover:text-[#d4a843] disabled:opacity-40 transition-colors"
title="Actualiser la liste des nœuds"
>
{discovering ? <span className="animate-spin inline-block"></span> : '↻ Actualiser'}
</button>
</div>
{discovering && discoveredUrls.length === 0 && (
<p className="text-xs text-[#4b5563] pl-1">Découverte en cours</p>
)}
{!discovering && discoveredUrls.length === 0 && (
<p className="text-xs text-[#4b5563] pl-1">Aucun nœud trouvé</p>
)}
{discoveredUrls.map((url) => (
<NodeRow key={url} url={url} />
))}
</div>
)}
{/* URL personnalisée */}
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">URL personnalisée</p>
<div className="flex gap-2">
<input
type="url"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder={service === 'subsquid' ? 'https://…/v1/graphql' : 'https://…'}
className="flex-1 bg-[#0a0b0f] border border-[#2e2f3a] rounded-xl px-3 py-2 text-white text-sm font-mono placeholder-[#2e2f3a] focus:outline-none focus:border-[#d4a843]/60 transition-colors"
/>
<button
onClick={() => testUrl(inputUrl.trim())}
disabled={!inputUrl.trim()}
className="text-xs text-[#4b5563] hover:text-[#d4a843] disabled:opacity-30 transition-colors px-3 py-2 border border-[#2e2f3a] rounded-xl"
>
Tester
</button>
</div>
{(() => {
const result = testResults.get(inputUrl.trim());
const isKnown = [...knownNodes, ...discoveredUrls.map((u) => ({ url: u }))].some(
(n) => n.url === inputUrl.trim()
);
if (!result || isKnown) return null;
return (
<p className="text-xs font-mono text-[#6b7280] pl-1">
{dot(result.state)}
{result.state === 'testing' && ' Test en cours…'}
{result.state === 'ok' && ` OK · ${result.latencyMs} ms`}
{result.state === 'slow' && ` Lent · ${result.latencyMs} ms`}
{result.state === 'error' && ' Inaccessible'}
</p>
);
})()}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[#4b5563] hover:text-white border border-[#2e2f3a] rounded-xl transition-colors"
>
Annuler
</button>
<button
onClick={handleSave}
disabled={!inputUrl.trim()}
className="px-4 py-2 text-sm font-medium bg-[#d4a843] text-[#0a0b0f] rounded-xl hover:bg-[#e0b84d] disabled:opacity-30 transition-colors"
>
Utiliser
</button>
</div>
</div>
</div>,
document.body
);
}
+87 -16
View File
@@ -24,6 +24,8 @@ const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux v
import type { TransactionArc } from '../data/arcData'; import type { TransactionArc } from '../data/arcData';
import { buildCorridors } from '../data/arcData'; import { buildCorridors } from '../data/arcData';
import type { TxCategory } from '../data/commentParser';
import { CATEGORY_LABELS, CATEGORY_COLORS, aggregateCategories } from '../data/commentParser';
// Leaflet default marker fix (Vite asset pipeline) // Leaflet default marker fix (Vite asset pipeline)
import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconUrl from 'leaflet/dist/images/marker-icon.png';
@@ -169,6 +171,10 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
interface ClusterArc { interface ClusterArc {
fromIdx: number; toIdx: number; fromIdx: number; toIdx: number;
totalVolume: number; count: number; totalVolume: number; count: number;
categories: { category: TxCategory; count: number; volume: number }[];
comments: string[];
_catItems: { category: TxCategory; amount: number }[];
_comments: string[];
} }
const clArcMap = new Map<string, ClusterArc>(); const clArcMap = new Map<string, ClusterArc>();
for (const c of corridors) { for (const c of corridors) {
@@ -176,12 +182,18 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
const ti = cityClusterIdx.get(c.toCity); const ti = cityClusterIdx.get(c.toCity);
if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré
const key = `${fi}||${ti}`; const key = `${fi}||${ti}`;
if (!clArcMap.has(key)) clArcMap.set(key, { fromIdx: fi, toIdx: ti, totalVolume: 0, count: 0 }); if (!clArcMap.has(key)) clArcMap.set(key, { fromIdx: fi, toIdx: ti, totalVolume: 0, count: 0, categories: [], comments: [], _catItems: [], _comments: [] });
const ca = clArcMap.get(key)!; const ca = clArcMap.get(key)!;
ca.totalVolume += c.totalVolume; ca.totalVolume += c.totalVolume;
ca.count += c.count; ca.count += c.count;
ca._catItems.push(...c.categories.map((cat) => ({ category: cat.category, amount: cat.volume })));
ca._comments.push(...c.comments);
} }
const clusterArcs = [...clArcMap.values()].sort((a, b) => b.totalVolume - a.totalVolume); const clusterArcs = [...clArcMap.values()].map((ca) => ({
...ca,
categories: aggregateCategories(ca._catItems),
comments: [...new Set(ca._comments)].filter(Boolean).slice(0, 4),
})).sort((a, b) => b.totalVolume - a.totalVolume);
// --- 4. Couleur de balance par cluster --- // --- 4. Couleur de balance par cluster ---
const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1); const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1);
@@ -239,9 +251,13 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
: isFocusTo ? '#00acc1' : isFocusTo ? '#00acc1'
: '#2e2f3a'; : '#2e2f3a';
const arcKey = `${ca.fromIdx}||${ca.toIdx}`;
const midX = (1-0.5)*(1-0.5)*p1.x + 2*(1-0.5)*0.5*cx + 0.5*0.5*p2.x;
const midY = (1-0.5)*(1-0.5)*p1.y + 2*(1-0.5)*0.5*cy + 0.5*0.5*p2.y;
return { return {
idx, ca, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill, idx, ca, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`, path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`,
arcKey, midX, midY,
}; };
}); });
@@ -263,9 +279,10 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
}, [corridors, cityNodes, focusCity, tick, mapReady, clustered]); }, [corridors, cityNodes, focusCity, tick, mapReady, clustered]);
const [popupIdx, setPopupIdx] = useState<number | null>(null); const [popupIdx, setPopupIdx] = useState<number | null>(null);
const [hoveredArc, setHoveredArc] = useState<{ key: string; x: number; y: number } | null>(null);
// Ferme le popup sur déplacement/zoom // Ferme le popup et le tooltip sur déplacement/zoom
useEffect(() => { setPopupIdx(null); }, [tick]); useEffect(() => { setPopupIdx(null); setHoveredArc(null); }, [tick]);
// Handler de clic : ouvre/ferme le popup + focus // Handler de clic : ouvre/ferme le popup + focus
const handleNodeClick = (nodeIdx: number) => { const handleNodeClick = (nodeIdx: number) => {
@@ -303,18 +320,22 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
</defs> </defs>
{/* Arcs bezier */} {/* Arcs bezier */}
{svgElements.arcElems.map(a => ( <g style={{ pointerEvents: 'all' }}>
<g key={`${a.ca.fromIdx}-${a.ca.toIdx}`} opacity={a.opacity}> {svgElements.arcElems.map(a => (
<path <g
d={a.path} key={`${a.ca.fromIdx}-${a.ca.toIdx}`}
fill="none" opacity={hoveredArc && hoveredArc.key !== a.arcKey ? a.opacity * 0.4 : a.opacity}
stroke={a.stroke} onMouseEnter={() => setHoveredArc({ key: a.arcKey, x: a.midX, y: a.midY })}
strokeWidth={a.strokeW} onMouseLeave={() => setHoveredArc(null)}
strokeLinecap="round" style={{ cursor: 'default' }}
/> >
<polygon points={a.arrowPts} fill={a.arrowFill} /> {/* Zone de hit invisible plus large */}
</g> <path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(2, a.strokeW)} pointerEvents="stroke" />
))} <path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" pointerEvents="stroke" />
<polygon points={a.arrowPts} fill={a.arrowFill} />
</g>
))}
</g>
{/* Nœuds de clusters (pointer-events activés uniquement ici) */} {/* Nœuds de clusters (pointer-events activés uniquement ici) */}
<g style={{ pointerEvents: 'all' }}> <g style={{ pointerEvents: 'all' }}>
@@ -347,6 +368,56 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
</svg> </svg>
)} )}
{/* Tooltip arc — nature des échanges */}
{hoveredArc && svgElements && (() => {
const arcElem = svgElements.arcElems.find((a) => a.arcKey === hoveredArc.key);
if (!arcElem) return null;
const { ca } = arcElem;
const topCats = ca.categories.filter((c) => c.category !== 'migration' && c.category !== 'ticket').slice(0, 4);
const total = ca.categories.reduce((s, c) => s + c.count, 0);
const containerW = containerRef.current?.clientWidth ?? 600;
const containerH = containerRef.current?.clientHeight ?? 400;
const tipW = 200;
const tipH = 120;
const left = Math.min(hoveredArc.x + 12, containerW - tipW - 8);
const top = Math.min(Math.max(8, hoveredArc.y - tipH / 2), containerH - tipH - 8);
return (
<div
className="absolute z-[601] bg-[#0a0b0f]/97 border border-[#2e2f3a] rounded-xl p-3 shadow-2xl pointer-events-none"
style={{ left, top, width: tipW }}
>
<p className="text-[#4b5563] text-[10px] uppercase tracking-widest mb-2">
{ca.count} échanges · {ca.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
</p>
{topCats.length > 0 ? (
<div className="space-y-1.5 mb-2">
{topCats.map((c) => {
const pct = Math.round((c.count / total) * 100);
return (
<div key={c.category}>
<div className="flex justify-between mb-0.5">
<span className="text-[#9ca3af] text-[10px]">{CATEGORY_LABELS[c.category]}</span>
<span className="text-[#4b5563] text-[10px] font-mono">{pct}%</span>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-0.5">
<div className="h-0.5 rounded-full" style={{ width: `${pct}%`, backgroundColor: CATEGORY_COLORS[c.category] }} />
</div>
</div>
);
})}
</div>
) : null}
{ca.comments.length > 0 && (
<div className="border-t border-[#1e1f2a] pt-1.5 space-y-0.5">
{ca.comments.slice(0, 3).map((c, i) => (
<p key={i} className="text-[#4b5563] text-[10px] truncate italic">"{c}"</p>
))}
</div>
)}
</div>
);
})()}
{/* Bouton cluster / villes */} {/* Bouton cluster / villes */}
<button <button
onClick={() => setClustered(c => !c)} onClick={() => setClustered(c => !c)}
+30 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet.heat'; import 'leaflet.heat';
import type { Transaction } from '../data/mockData'; import type { Transaction } from '../data/mockData';
import type { MemberCity } from '../services/DataService';
// Leaflet default marker fix (Vite asset pipeline) // Leaflet default marker fix (Vite asset pipeline)
import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconUrl from 'leaflet/dist/images/marker-icon.png';
@@ -10,6 +11,7 @@ L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl });
interface HeatMapProps { interface HeatMapProps {
transactions: Transaction[]; transactions: Transaction[];
memberCities?: MemberCity[];
} }
const HEAT_OPTIONS: L.HeatMapOptions = { const HEAT_OPTIONS: L.HeatMapOptions = {
@@ -29,10 +31,11 @@ const HEAT_OPTIONS: L.HeatMapOptions = {
}, },
}; };
export function HeatMap({ transactions }: HeatMapProps) { export function HeatMap({ transactions, memberCities = [] }: HeatMapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null); const mapRef = useRef<L.Map | null>(null);
const heatRef = useRef<L.HeatLayer | null>(null); const heatRef = useRef<L.HeatLayer | null>(null);
const memberLayerRef = useRef<L.LayerGroup | null>(null);
// Two img overlays that cross-fade between each other. // Two img overlays that cross-fade between each other.
// The canvas opacity is NEVER touched — it stays at leaflet's default. // The canvas opacity is NEVER touched — it stays at leaflet's default.
const prevRef = useRef<HTMLImageElement | null>(null); const prevRef = useRef<HTMLImageElement | null>(null);
@@ -59,9 +62,11 @@ export function HeatMap({ transactions }: HeatMapProps) {
L.control.zoom({ position: 'bottomright' }).addTo(map); L.control.zoom({ position: 'bottomright' }).addTo(map);
const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map); const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map);
const memberLayer = L.layerGroup().addTo(map);
mapRef.current = map; mapRef.current = map;
heatRef.current = heat; heatRef.current = heat;
memberLayerRef.current = memberLayer;
// Pendant zoom/pan : cache les overlays → le canvas live est visible directement. // Pendant zoom/pan : cache les overlays → le canvas live est visible directement.
// Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné. // Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné.
@@ -100,9 +105,33 @@ export function HeatMap({ transactions }: HeatMapProps) {
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
heatRef.current = null; heatRef.current = null;
memberLayerRef.current = null;
}; };
}, []); }, []);
// Overlay membres DU : cercles proportionnels au nombre de membres par ville
useEffect(() => {
const layer = memberLayerRef.current;
if (!layer) return;
layer.clearLayers();
if (memberCities.length === 0) return;
const maxCount = Math.max(...memberCities.map((c) => c.count), 1);
for (const city of memberCities) {
const radius = 4 + Math.sqrt(city.count / maxCount) * 18;
L.circleMarker([city.lat, city.lng], {
radius,
color: '#00c853',
fillColor: '#00c853',
fillOpacity: 0.18,
weight: 1.5,
opacity: 0.7,
})
.bindTooltip(`<b>${city.city}</b><br/>${city.count} membre${city.count > 1 ? 's' : ''}`, { sticky: true })
.addTo(layer);
}
}, [memberCities]);
// Crossfade: two img overlays swap roles each frame. // Crossfade: two img overlays swap roles each frame.
// Canvas is never hidden — we only read its pixel data via toDataURL(). // Canvas is never hidden — we only read its pixel data via toDataURL().
useEffect(() => { useEffect(() => {
+65 -1
View File
@@ -83,11 +83,42 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
</Feature> </Feature>
</Section> </Section>
<Section title="Raccourcis clavier">
<Feature icon="⌨" name="Navigation animation">
<Kbd></Kbd> <Kbd></Kbd> frame précédente / suivante ·
<Kbd>Espace</Kbd> lecture / pause.
</Feature>
<Feature icon="⌨" name="Vues & panneaux">
<Kbd>H</Kbd> basculer Heatmap Flux ·
<Kbd>Échap</Kbd> quitter l'animation ou fermer ce panneau.
</Feature>
</Section>
<Section title="Recherche">
<Feature icon="⌕" name="Identité ou clé Ğ1">
Le bouton <Kbd></Kbd> (à gauche de la carte) accepte un nom d'identité Ğ1
(ex : "Alice") ou une clé publique <Kbd>g1</Kbd>.
Il bascule automatiquement en vue Flux et met la ville en focus.
</Feature>
</Section>
<Section title="URL partageable">
<Feature icon="🔗" name="Deep link">
L'URL reflète l'état courant : période, vue, ville sélectionnée.
Partager l'URL restitue exactement la même configuration.
Exemple : <span className="font-mono text-[#d4a843] text-xs">?period=30&view=flow&city=Paris</span>
</Feature>
</Section>
<Section title="Statistiques"> <Section title="Statistiques">
<Feature icon="📊" name="Panneau latéral"> <Feature icon="📊" name="Panneau latéral">
Volume total en Ğ1, nombre de transactions, top émetteurs et receveurs, Volume total en Ğ1, nombre de transactions, top émetteurs et receveurs,
répartition géographique. Se met à jour en temps réel et pendant l'animation. répartition géographique. Se met à jour en temps réel et pendant l'animation.
</Feature> </Feature>
<Feature icon="▂▅█" name="Sparkline">
Mini-graphique d'activité journalière affiché sous la période,
calculé depuis les timestamps déjà en mémoire.
</Feature>
<Feature icon="☰" name="Mobile"> <Feature icon="☰" name="Mobile">
Le panneau est accessible via le bouton <Kbd></Kbd> en haut à gauche. Le panneau est accessible via le bouton <Kbd></Kbd> en haut à gauche.
</Feature> </Feature>
@@ -96,9 +127,42 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
</Feature> </Feature>
</Section> </Section>
<Section title="Nature des échanges">
<Feature icon="🏷" name="Catégorisation automatique">
Le commentaire de chaque transaction est analysé et classé en catégories :
don & gratitude, alimentation, soin & bien-être, vêtements, culture & loisirs,
événement, service & travaux, remboursement.
</Feature>
<Feature icon="▬" name="Distribution dans le panneau">
La section <em>Nature des échanges</em> en bas du panneau latéral affiche
la répartition des catégories sous forme de barres proportionnelles
sur les transactions commentées de la période courante.
</Feature>
<Feature icon="⟿" name="Tooltip sur les arcs (vue Flux)">
Survoler un arc affiche la distribution des catégories et un échantillon
de commentaires bruts pour ce corridor.
</Feature>
</Section>
<Section title="Overlay Dividende Universel">
<Feature icon="DU" name="Membres actifs géolocalisés">
Le bouton <Kbd>DU</Kbd> (à gauche de la carte) affiche en overlay les membres Ğ1
actifs (WoT) ayant un profil Cesium+ géolocalisé.
Chaque point représente une ville avec des membres actifs.
</Feature>
</Section>
<Section title="Source de données"> <Section title="Source de données">
<Feature icon="●" name="Live Ğ1v2"> <Feature icon="●" name="Live Ğ1v2">
Données temps réel de la blockchain Ğ1v2, actualisées toutes les 30 secondes. Données temps réel de la blockchain Ğ1v2 via SubSquid, actualisées toutes les 30 secondes.
Les profils de géolocalisation sont fournis par Cesium+.
</Feature>
<Feature icon="●" name="Statut des services">
Deux indicateurs en bas du panneau latéral affichent l'état de SubSquid et Cesium+ en temps réel
(<span className="text-emerald-400">vert</span> OK ·{' '}
<span className="text-amber-400">jaune</span> lent ·{' '}
<span className="text-red-400">rouge</span> inaccessible).
Un clic sur un indicateur permet de configurer ou changer l'endpoint.
</Feature> </Feature>
</Section> </Section>
+151
View File
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { ss58ToDuniterKey, SUBSQUID_ENDPOINT } from '../services/adapters/SubsquidAdapter';
import { resolveGeoByKeys } from '../services/adapters/CesiumAdapter';
interface SearchBarProps {
/** Appelé quand une ville est trouvée — App bascule en vue flux et met la ville en focus. */
onResult: (city: string) => void;
}
async function resolveQuery(query: string): Promise<{ name: string; city: string } | null> {
const q = query.trim();
if (!q) return null;
// Clé SS58 Ğ1v2 : commence par "g1" et fait ~50 caractères
const isKey = /^g1[1-9A-HJ-NP-Za-km-z]{40,}$/.test(q);
let duniterKey: string;
let identityName: string;
if (isKey) {
duniterKey = ss58ToDuniterKey(q);
identityName = q.slice(0, 10) + '…';
} else {
const res = await fetch(SUBSQUID_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query($q: String!) {
identities(filter: { name: { includesInsensitive: $q } }, first: 1) {
nodes {
accountId
name
ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) {
nodes { previousId }
}
}
}
}
`,
variables: { q },
}),
});
if (!res.ok) throw new Error(`Subsquid HTTP ${res.status}`);
const data = await res.json();
const node = data?.data?.identities?.nodes?.[0];
if (!node) return null;
const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId;
duniterKey = ss58ToDuniterKey(genesisKey);
identityName = node.name as string;
}
const geoMap = await resolveGeoByKeys([duniterKey]);
const geo = geoMap.get(duniterKey);
if (!geo) return null;
return { name: identityName, city: geo.city.split(',')[0].trim() };
}
export function SearchBar({ onResult }: SearchBarProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [found, setFound] = useState<{ name: string; city: string } | null>(null);
const close = () => { setOpen(false); setQuery(''); setError(null); setFound(null); };
const handleSubmit = async () => {
if (!query.trim()) return;
setLoading(true);
setError(null);
setFound(null);
try {
const result = await resolveQuery(query);
if (result) setFound(result);
else setError('Introuvable dans Cesium+');
} catch {
setError('Erreur de connexion');
} finally {
setLoading(false);
}
};
const handleSelect = () => {
if (!found) return;
onResult(found.city);
close();
};
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#6b7280] hover:text-[#d4a843] transition-colors text-sm"
aria-label="Rechercher une identité Ğ1"
title="Rechercher une identité (nom ou clé g1…)"
>
</button>
);
}
return (
<div className="flex flex-col gap-1.5 bg-[#0a0b0f]/95 backdrop-blur-sm border border-[#2e2f3a] rounded-xl p-2 w-60 shadow-xl">
<div className="flex gap-1 items-center">
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') close();
}}
placeholder="Nom ou clé g1…"
className="flex-1 min-w-0 bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-2 py-1.5 text-xs text-white placeholder-[#4b5563] focus:outline-none focus:border-[#d4a843] transition-colors"
/>
<button
onClick={handleSubmit}
disabled={loading || !query.trim()}
className="text-[#d4a843] disabled:text-[#4b5563] text-sm px-1.5 hover:text-white transition-colors shrink-0"
aria-label="Rechercher"
>
{loading ? <span className="animate-spin inline-block"></span> : '↵'}
</button>
<button
onClick={close}
className="text-[#4b5563] hover:text-white transition-colors shrink-0 text-xs"
aria-label="Fermer"
>
</button>
</div>
{error && (
<p className="text-red-400 text-xs px-1">{error}</p>
)}
{found && (
<button
onClick={handleSelect}
className="text-left px-2 py-2 rounded-lg bg-[#1e1f2a] hover:bg-[#2e2f3a] transition-colors"
>
<p className="text-[#d4a843] text-xs font-medium truncate">{found.name}</p>
<p className="text-[#6b7280] text-xs mt-0.5">📍 {found.city} cliquer pour zoomer</p>
</button>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react';
import type { ServiceStatus, ServiceState } from '../hooks/useServiceStatus';
import { EndpointPopover } from './EndpointPopover';
interface Props {
subsquid: ServiceStatus;
cesium: ServiceStatus;
onEndpointChange: () => void;
}
const STATE_COLOR: Record<ServiceState, string> = {
checking: 'text-[#4b5563] animate-pulse',
ok: 'text-emerald-400',
slow: 'text-amber-400',
error: 'text-red-500',
};
function Dot({ status, label, onClick }: { status: ServiceStatus; label: string; onClick: () => void }) {
const latency = status.latencyMs !== null ? ` · ${status.latencyMs} ms` : '';
const title = `${label}${STATUS_LABEL_FULL[status.state]}${latency}\n${status.url}`;
return (
<button
onClick={onClick}
title={title}
className="flex items-center gap-1 text-[10px] font-mono text-[#4b5563] hover:text-white transition-colors group"
>
<span className={STATE_COLOR[status.state]}></span>
<span className="group-hover:text-[#6b7280]">
{label}
{status.latencyMs !== null && (
<span className="text-[#2e2f3a] ml-0.5">{status.latencyMs}ms</span>
)}
</span>
</button>
);
}
const STATUS_LABEL_FULL: Record<ServiceState, string> = {
checking: 'Vérification…',
ok: 'Accessible',
slow: 'Réponse lente',
error: 'Inaccessible',
};
export function ServiceStatusDots({ subsquid, cesium, onEndpointChange }: Props) {
const [popover, setPopover] = useState<'subsquid' | 'cesium' | null>(null);
const hasError = subsquid.state === 'error' || cesium.state === 'error';
const erroredService = subsquid.state === 'error' ? 'SubSquid' : 'Cesium+';
const erroredKey: 'subsquid' | 'cesium' = subsquid.state === 'error' ? 'subsquid' : 'cesium';
return (
<>
{/* Bannière d'erreur */}
{hasError && (
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 text-xs">
<span className="text-red-400">
<span className="text-red-500 mr-1"></span>
{erroredService} inaccessible
</span>
<button
onClick={() => setPopover(erroredKey)}
className="text-[#d4a843] hover:text-[#e0b84d] transition-colors font-medium ml-2"
>
Configurer
</button>
</div>
)}
{/* Dots */}
<div className="flex items-center gap-3">
<Dot status={subsquid} label="SubSquid" onClick={() => setPopover('subsquid')} />
<Dot status={cesium} label="Cesium+" onClick={() => setPopover('cesium')} />
</div>
{/* Popover de configuration */}
{popover && (
<EndpointPopover
service={popover}
onClose={() => setPopover(null)}
onSaved={onEndpointChange}
/>
)}
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from 'react';
interface SparklineProps {
timestamps: number[];
periodDays: number;
}
/**
* Mini bar-chart SVG affichant l'activité journalière sur la période.
* Utilise les timestamps déjà en mémoire aucune requête supplémentaire.
*/
export function Sparkline({ timestamps, periodDays }: SparklineProps) {
const buckets = useMemo(() => {
if (timestamps.length === 0) return [];
const n = periodDays === 1 ? 24 : Math.min(periodDays, 30);
const now = Date.now();
const start = now - periodDays * 864e5;
const step = (periodDays * 864e5) / n;
const counts = new Array(n).fill(0);
for (const ts of timestamps) {
const i = Math.floor((ts - start) / step);
if (i >= 0 && i < n) counts[i]++;
}
return counts;
}, [timestamps, periodDays]);
if (buckets.length === 0) return null;
const n = buckets.length;
const max = Math.max(...buckets, 1);
const W = 100;
const H = 32;
const barW = W / n;
const gap = barW * 0.18;
return (
<div className="space-y-1">
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="w-full h-8"
aria-hidden="true"
>
{buckets.map((count, i) => {
const h = Math.max(1, (count / max) * H);
return (
<rect
key={i}
x={i * barW + gap / 2}
y={H - h}
width={barW - gap}
height={h}
fill="#d4a843"
opacity={0.25 + 0.75 * (count / max)}
rx={0.5}
/>
);
})}
</svg>
<div className="flex justify-between text-[10px] text-[#4b5563]">
<span>{periodDays === 1 ? '0h' : 'J-' + periodDays}</span>
<span>{periodDays === 1 ? 'maintenant' : 'aujourd\'hui'}</span>
</div>
</div>
);
}
+96 -10
View File
@@ -1,6 +1,11 @@
import { useRef } from 'react'; import { useRef, useState } from 'react';
import type { PeriodStats } from '../services/DataService'; import type { PeriodStats } from '../services/DataService';
import type { FlowStats } from '../data/arcData'; import type { FlowStats } from '../data/arcData';
import { Sparkline } from './Sparkline';
import { ServiceStatusDots } from './ServiceStatusDots';
import { useServiceStatus } from '../hooks/useServiceStatus';
import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser';
import type { Transaction } from '../data/mockData';
interface StatsPanelProps { interface StatsPanelProps {
stats: PeriodStats | null; stats: PeriodStats | null;
@@ -14,6 +19,9 @@ interface StatsPanelProps {
flowStats?: FlowStats | null; flowStats?: FlowStats | null;
focusCity?: string | null; focusCity?: string | null;
onClose?: () => void; onClose?: () => void;
onEndpointChange?: () => void;
allTimestamps?: number[];
transactions?: Transaction[];
} }
const MEDALS = ['🥇', '🥈', '🥉']; const MEDALS = ['🥇', '🥈', '🥉'];
@@ -60,7 +68,9 @@ function CityRow({ city, volume, count, countryCode, accent }: {
); );
} }
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className }: StatsPanelProps) { export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
const { subsquid, cesium, recheck } = useServiceStatus();
const [openCategory, setOpenCategory] = useState<TxCategory | null>(null);
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null); const prevStats = useRef<PeriodStats | null>(null);
@@ -95,7 +105,11 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
Ğ1Flux Ğ1Flux
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span> <span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
</h1> </h1>
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p> <ServiceStatusDots
subsquid={subsquid}
cesium={cesium}
onEndpointChange={() => { recheck(); onEndpointChange?.(); }}
/>
</div> </div>
{onClose && ( {onClose && (
<button <button
@@ -113,13 +127,18 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale. Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
</p> </p>
{/* Period label */} {/* Period label + sparkline */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3"> <div className="border-t border-[#1e1f2a] pt-3 space-y-2">
{animationLabel <p className="text-[#4b5563] text-xs">
? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></> {animationLabel
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></> ? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></>
} : <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
</p> }
</p>
{!animationLabel && allTimestamps.length > 0 && (
<Sparkline timestamps={allTimestamps} periodDays={periodDays} />
)}
</div>
{/* ---- Vue HEATMAP ---- */} {/* ---- Vue HEATMAP ---- */}
{viewMode === 'heatmap' && ( {viewMode === 'heatmap' && (
@@ -294,6 +313,73 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
</> </>
)} )}
{/* Nature des échanges */}
{!loading && stats && stats.categoryBreakdown.length > 0 && stats.commentedCount > 0 && (
<div className="space-y-2 border-t border-[#1e1f2a] pt-3">
<div className="flex items-center justify-between">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nature des échanges</p>
<p className="text-[#2e2f3a] text-[10px] font-mono">{stats.commentedCount} commentés</p>
</div>
<div className="space-y-1">
{stats.categoryBreakdown
.filter((c) => c.category !== 'migration' && c.category !== 'ticket')
.slice(0, 7)
.map((c) => {
const pct = Math.round((c.count / stats.commentedCount) * 100);
const isOpen = openCategory === c.category;
const detail = transactions.filter((t) => t.category === c.category && t.comment);
return (
<div key={c.category}>
<button
onClick={() => setOpenCategory(isOpen ? null : c.category)}
className="w-full text-left group"
>
<div className="flex items-center justify-between mb-0.5">
<span className={`text-[11px] transition-colors ${isOpen ? 'text-white' : 'text-[#9ca3af] group-hover:text-white'}`}>
{isOpen ? '▾' : '▸'} {CATEGORY_LABELS[c.category]}
</span>
<span className="text-[#4b5563] text-[10px] font-mono">{c.count} · {pct}%</span>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1">
<div
className="h-1 rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: CATEGORY_COLORS[c.category] }}
/>
</div>
</button>
{isOpen && (
<div className="mt-1.5 mb-1 bg-[#0a0b0f] border border-[#1e1f2a] rounded-lg overflow-hidden">
{detail.length === 0 ? (
<p className="text-[#4b5563] text-[10px] px-3 py-2">Aucun commentaire disponible.</p>
) : (
<div className="max-h-48 overflow-y-auto divide-y divide-[#1e1f2a]">
{detail.slice(0, 30).map((t) => (
<div key={t.id} className="px-3 py-1.5 flex items-start justify-between gap-2">
<p className="text-[#9ca3af] text-[10px] italic leading-snug flex-1 min-w-0 truncate">
"{t.comment}"
</p>
<span className="text-[#d4a843] text-[10px] font-mono shrink-0">
{t.amount.toLocaleString('fr-FR', { maximumFractionDigits: 1 })} Ğ1
</span>
</div>
))}
{detail.length > 30 && (
<p className="text-[#4b5563] text-[10px] px-3 py-1.5 text-center">
+{detail.length - 30} autres
</p>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Footer */} {/* Footer */}
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5"> <div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
<p className="text-[#2e2f3a] text-xs text-center"> <p className="text-[#2e2f3a] text-xs text-center">
+25 -8
View File
@@ -1,4 +1,6 @@
import type { Transaction } from './mockData'; import type { Transaction } from './mockData';
import type { TxCategory } from './commentParser';
import { aggregateCategories } from './commentParser';
export interface TransactionArc { export interface TransactionArc {
id: string; id: string;
@@ -14,6 +16,8 @@ export interface TransactionArc {
toCity: string; toCity: string;
toCountry: string; toCountry: string;
toKey: string; toKey: string;
comment: string | null;
category: TxCategory;
} }
/** Corridor agrégé par paire de villes (fromCity → toCity). */ /** Corridor agrégé par paire de villes (fromCity → toCity). */
@@ -28,6 +32,8 @@ export interface Corridor {
toCountry: string; toCountry: string;
totalVolume: number; totalVolume: number;
count: number; count: number;
categories: { category: TxCategory; count: number; volume: number }[];
comments: string[]; // échantillon de commentaires bruts (max 5, non nuls)
} }
export interface FlowStats { export interface FlowStats {
@@ -40,21 +46,30 @@ export interface FlowStats {
/** Agrège les arcs individuels en corridors ville→ville, triés par volume. */ /** Agrège les arcs individuels en corridors ville→ville, triés par volume. */
export function buildCorridors(arcs: TransactionArc[]): Corridor[] { export function buildCorridors(arcs: TransactionArc[]): Corridor[] {
const map = new Map<string, Corridor>(); const map = new Map<string, { corridor: Omit<Corridor, 'categories' | 'comments'>; items: TransactionArc[] }>();
for (const arc of arcs) { for (const arc of arcs) {
const key = `${arc.fromCity}||${arc.toCity}`; const key = `${arc.fromCity}||${arc.toCity}`;
if (!map.has(key)) { if (!map.has(key)) {
map.set(key, { map.set(key, {
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry, corridor: {
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry, fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
totalVolume: 0, count: 0, toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
totalVolume: 0, count: 0,
},
items: [],
}); });
} }
const c = map.get(key)!; const entry = map.get(key)!;
c.totalVolume += arc.amount; entry.corridor.totalVolume += arc.amount;
c.count++; entry.corridor.count++;
entry.items.push(arc);
} }
return [...map.values()].sort((a, b) => b.totalVolume - a.totalVolume);
return [...map.values()].map(({ corridor, items }) => ({
...corridor,
categories: aggregateCategories(items.map((a) => ({ category: a.category, amount: a.amount }))),
comments: items.map((a) => a.comment).filter((c): c is string => !!c).slice(0, 5),
})).sort((a, b) => b.totalVolume - a.totalVolume);
} }
export function computeFlowStats(arcs: TransactionArc[]): FlowStats { export function computeFlowStats(arcs: TransactionArc[]): FlowStats {
@@ -114,6 +129,8 @@ export function buildMockArcs(transactions: Transaction[]): TransactionArc[] {
toLat: to.lat, toLng: to.lng, toLat: to.lat, toLng: to.lng,
toCity: to.city, toCountry: to.countryCode, toCity: to.city, toCountry: to.countryCode,
toKey: to.toKey, toKey: to.toKey,
comment: from.comment,
category: from.category,
}); });
} }
return arcs; return arcs;
+300
View File
@@ -0,0 +1,300 @@
export type TxCategory =
| 'migration'
| 'ticket'
| 'remboursement'
| 'don'
| 'alimentation'
| 'soin'
| 'vetements'
| 'culture'
| 'evenement'
| 'service'
| 'autre';
export const CATEGORY_LABELS: Record<TxCategory, string> = {
migration: 'Migration',
ticket: 'Ticket',
remboursement:'Remboursement',
don: 'Don / Gratitude',
alimentation: 'Alimentation',
soin: 'Soin & bien-être',
vetements: 'Vêtements',
culture: 'Culture & loisirs',
evenement: 'Événement',
service: 'Service & travaux',
autre: 'Autre',
};
export const CATEGORY_COLORS: Record<TxCategory, string> = {
migration: '#4b5563',
ticket: '#6b7280',
remboursement:'#f59e0b',
don: '#ec4899',
alimentation: '#22c55e',
soin: '#06b6d4',
vetements: '#a78bfa',
culture: '#f97316',
evenement: '#eab308',
service: '#3b82f6',
autre: '#374151',
};
// ---------------------------------------------------------------------------
// Règles de détection — ordre = priorité, première règle qui matche gagne
// ---------------------------------------------------------------------------
interface Rule {
category: TxCategory;
patterns: RegExp[];
}
const RULES: Rule[] = [
{
category: 'migration',
patterns: [
/ğecko:csmigration/i,
/csmigration/i,
/migration\s*v[12]/i,
/\bğecko\b/i,
],
},
{
category: 'ticket',
patterns: [
/\bticket\s+\d{6,}/i,
],
},
{
category: 'remboursement',
patterns: [
/\bretour\b/i,
/\brendu\b/i,
/\bremboursement\b/i,
/\bdevolución\b/i,
/\bdevolucio\b/i,
/\brimborso\b/i,
/\brégul\b/i,
/\bregulariz/i,
/double\s*paiement/i,
],
},
{
category: 'don',
patterns: [
/\bdon\b/i,
/\bdonación\b/i,
/\bdonazione\b/i,
/\bdonacio\b/i,
/\bcadeau\b/i,
/\bgratitud/i,
/\bgratitude\b/i,
/\bmerci\b/i,
/\bgracias\b/i,
/\bgràcies\b/i,
/\bgracies\b/i,
/\bobrigad/i,
/\bthank/i,
/\bgràcia/i,
/\bgrazie\b/i,
/\bgrazie\b/i,
/\bbienvenu/i,
/\bwelcome\b/i,
/\bchukurei\b/i,
],
},
{
category: 'alimentation',
patterns: [
/\brepas\b/i,
/\bpaella\b/i,
/\bcrêpe\b/i,
/\bcrepe\b/i,
/\bfalafel\b/i,
/\bpain\b/i,
/\bpan\b/i,
/\bgâteau/i,
/\bgateau\b/i,
/\bgalleta/i,
/\bpastis\b/i,
/\bpastel\b/i,
/\bburger\b/i,
/\bkombucha\b/i,
/\bœuf/i,
/\boeufs?\b/i,
/\bhuevo/i,
/\bfromage\b/i,
/\bflan\b/i,
/\balgue/i,
/\blegum/i,
/\bfruits?\b/i,
/\bpomme/i,
/\blimonad/i,
/\blimonada\b/i,
/\blégumin/i,
/\bporro\b/i,
/\bcarbassa\b/i,
/\bsobrasada\b/i,
/\biarmelada\b/i,
/\bnispero/i,
/\bbizcocho\b/i,
/\bchocolat/i,
/\balmendra/i,
/\bincienso/i,
/\bincens/i,
/alimentation/i,
/\bépice/i,
/\bcava\b/i,
/\bvin\b/i,
/\baceit/i,
/huile\s*d.?olive/i,
/\bgerminado/i,
],
},
{
category: 'soin',
patterns: [
/\bsoin\b/i,
/\bmassage\b/i,
/\bbaume\b/i,
/\bhuile\s*essenti/i,
/\btisane\b/i,
/\bterapia\b/i,
/\bthérapie\b/i,
/\bherboristerie\b/i,
/\bplante/i,
/\bhomeopat/i,
/\baromath/i,
/\breiki\b/i,
/\bacupunct/i,
/\bostéo/i,
/\bkinesio/i,
/\btirage\b/i,
/\bcart(e|as)\b/i,
/\bmandalas?\b/i,
/\bconsoude\b/i,
/\bsauge\b/i,
/\bromarin\b/i,
/\bserum\b/i,
/\bsérum\b/i,
/\bpeeling\b/i,
/\bbifasico\b/i,
/\bormus\b/i,
/eau\s*de\s*mer/i,
],
},
{
category: 'vetements',
patterns: [
/\bjupe\b/i,
/\bpantalon\b/i,
/\bblouson\b/i,
/\bchaussure/i,
/\bvêtement/i,
/\bropa\b/i,
/\bcardigan\b/i,
/\bmanteau\b/i,
/\bchemise\b/i,
/\btricot\b/i,
/\blaine\b/i,
/\btissus?\b/i,
],
},
{
category: 'culture',
patterns: [
/\blivre\b/i,
/\blivres\b/i,
/\blibro\b/i,
/\bmusique\b/i,
/\bmusica\b/i,
/\bmúsica\b/i,
/\bconcierto\b/i,
/\bconcert\b/i,
/\bcd\b/i,
/\bvídeo\b/i,
/\bvideo\b/i,
/\bpelícula\b/i,
/\bfilm\b/i,
/\bpoème\b/i,
/\bpoema\b/i,
/\bbd\b/i,
/\bdessin\b/i,
/\bnexus\b/i,
/\bnaruto\b/i,
],
},
{
category: 'evenement',
patterns: [
/\bg1ntada\b/i,
/\bğ1ntada\b/i,
/\bmercat\b/i,
/\bmarché\b/i,
/\bjornadas?\b/i,
/\bfestival\b/i,
/\bfête\b/i,
/\bfiesta\b/i,
/\brassemblement\b/i,
/\bcampillo\b/i,
/\beutopia\b/i,
/\brencontre\b/i,
],
},
{
category: 'service',
patterns: [
/\bservice\b/i,
/\batelier\b/i,
/\baccompagnement\b/i,
/\btravaux\b/i,
/\bbarnum\b/i,
/\baccueil\b/i,
/\bhébergement\b/i,
/\blogement\b/i,
/\bnuit\b/i,
/\bnuits\b/i,
/\bnit\b/i,
/\bnits\b/i,
/\bvisita\b/i,
/\bamortigua/i,
/\bamortisseur/i,
/\bréparation\b/i,
],
},
];
export function parseComment(remark: string | null): TxCategory {
if (!remark) return 'autre';
const text = remark.trim();
if (!text) return 'autre';
for (const rule of RULES) {
if (rule.patterns.some((p) => p.test(text))) {
return rule.category;
}
}
return 'autre';
}
// ---------------------------------------------------------------------------
// Agrégation sur un tableau de catégories
// ---------------------------------------------------------------------------
export interface CategoryCount {
category: TxCategory;
count: number;
volume: number;
}
export function aggregateCategories(
items: { category: TxCategory; amount: number }[]
): CategoryCount[] {
const map = new Map<TxCategory, CategoryCount>();
for (const { category, amount } of items) {
if (!map.has(category)) map.set(category, { category, count: 0, volume: 0 });
const entry = map.get(category)!;
entry.count++;
entry.volume += amount;
}
return [...map.values()].sort((a, b) => b.count - a.count);
}
+20 -1
View File
@@ -1,3 +1,5 @@
import type { TxCategory } from './commentParser';
export interface Transaction { export interface Transaction {
id: string; id: string;
timestamp: number; // Unix ms (entier) timestamp: number; // Unix ms (entier)
@@ -8,6 +10,8 @@ export interface Transaction {
countryCode: string; // ISO 3166-1 alpha-2, ex: "FR" countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars
toKey: string; toKey: string;
comment: string | null;
category: TxCategory;
} }
// French + European cities where Ğ1 is used // French + European cities where Ğ1 is used
@@ -79,6 +83,8 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
countryCode: 'FR', countryCode: 'FR',
fromKey: generateKey(), fromKey: generateKey(),
toKey: generateKey(), toKey: generateKey(),
comment: null,
category: 'autre',
}); });
} }
@@ -114,7 +120,20 @@ export function computeStats(transactions: Transaction[]) {
.slice(0, 3) .slice(0, 3)
.map(([name, data]) => ({ name, ...data })); .map(([name, data]) => ({ name, ...data }));
return { totalVolume, transactionCount, topCities }; const catMap = new Map<import('./commentParser').TxCategory, { count: number; volume: number }>();
let commentedCount = 0;
for (const tx of transactions) {
if (tx.comment) commentedCount++;
const entry = catMap.get(tx.category) ?? { count: 0, volume: 0 };
entry.count++;
entry.volume += tx.amount;
catMap.set(tx.category, entry);
}
const categoryBreakdown = [...catMap.entries()]
.map(([category, v]) => ({ category, ...v }))
.sort((a, b) => b.count - a.count);
return { totalVolume, transactionCount, topCities, categoryBreakdown, commentedCount };
} }
export type { }; export type { };
+118
View File
@@ -0,0 +1,118 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getSubsquidUrl, getCesiumUrl } from '../services/EndpointConfig';
export type ServiceState = 'checking' | 'ok' | 'slow' | 'error';
export interface ServiceStatus {
state: ServiceState;
latencyMs: number | null;
url: string;
}
export interface ServicesStatus {
subsquid: ServiceStatus;
cesium: ServiceStatus;
recheck: () => void;
}
const TIMEOUT_MS = 8_000;
const SLOW_THRESHOLD_MS = 2_000;
const POLL_INTERVAL_MS = 60_000;
async function pingSubsquid(url: string): Promise<number> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = Date.now();
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '{ __typename }' }),
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await res.json();
return Date.now() - start;
} finally {
clearTimeout(timer);
}
}
async function pingCesium(url: string): Promise<number> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = Date.now();
try {
const res = await fetch(`${url}/user/profile/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ size: 0, query: { match_all: {} } }),
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return Date.now() - start;
} finally {
clearTimeout(timer);
}
}
function latencyToState(ms: number): ServiceState {
if (ms < SLOW_THRESHOLD_MS) return 'ok';
return 'slow';
}
const CHECKING: ServiceStatus = { state: 'checking', latencyMs: null, url: '' };
export function useServiceStatus(): ServicesStatus {
const [subsquid, setSubsquid] = useState<ServiceStatus>(CHECKING);
const [cesium, setCesium] = useState<ServiceStatus>(CHECKING);
const [version, setVersion] = useState(0);
const cancelled = useRef(false);
const runChecks = useCallback(async () => {
const squidUrl = getSubsquidUrl();
const cesiumUrl = getCesiumUrl();
setSubsquid({ state: 'checking', latencyMs: null, url: squidUrl });
setCesium( { state: 'checking', latencyMs: null, url: cesiumUrl });
const [squidResult, cesiumResult] = await Promise.allSettled([
pingSubsquid(squidUrl),
pingCesium(cesiumUrl),
]);
if (cancelled.current) return;
if (squidResult.status === 'fulfilled') {
const ms = squidResult.value;
setSubsquid({ state: latencyToState(ms), latencyMs: ms, url: squidUrl });
} else {
setSubsquid({ state: 'error', latencyMs: null, url: squidUrl });
}
if (cesiumResult.status === 'fulfilled') {
const ms = cesiumResult.value;
setCesium({ state: latencyToState(ms), latencyMs: ms, url: cesiumUrl });
} else {
setCesium({ state: 'error', latencyMs: null, url: cesiumUrl });
}
}, [version]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
cancelled.current = false;
runChecks();
const interval = setInterval(runChecks, POLL_INTERVAL_MS);
return () => {
cancelled.current = true;
clearInterval(interval);
};
}, [runChecks]);
const recheck = useCallback(() => setVersion((v) => v + 1), []);
return { subsquid, cesium, recheck };
}
export async function testEndpoint(type: 'subsquid' | 'cesium', url: string): Promise<number> {
return type === 'subsquid' ? pingSubsquid(url) : pingCesium(url);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* useUrlState synchronisation bidirectionnelle de l'état App URL.
*
* Lecture initiale : appelée une fois au démarrage (module-level).
* Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour.
*
* Paramètres supportés :
* ?period=7&view=flow&city=Paris
*/
import { useEffect } from 'react';
function parseInitialState(): { period: number; view: 'heatmap' | 'flow'; city: string | null } {
const p = new URLSearchParams(window.location.search);
const period = parseInt(p.get('period') ?? '', 10);
return {
period: Number.isFinite(period) && period >= 1 && period <= 365 ? period : 7,
view: p.get('view') === 'flow' ? 'flow' : 'heatmap',
city: p.get('city') ?? null,
};
}
/** Valeurs lues depuis l'URL au chargement de la page. */
export const initialUrlState = parseInitialState();
/** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */
export function useUrlSync(
periodDays: number,
viewMode: 'heatmap' | 'flow',
focusCity: string | null,
) {
useEffect(() => {
const p = new URLSearchParams();
if (periodDays !== 7) p.set('period', String(periodDays));
if (viewMode !== 'heatmap') p.set('view', viewMode);
if (focusCity) p.set('city', focusCity);
const qs = p.toString();
history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname);
}, [periodDays, viewMode, focusCity]);
}
+65 -6
View File
@@ -12,8 +12,9 @@
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local * Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/ */
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter'; import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter'; import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
import { parseComment } from '../data/commentParser';
import { import {
getTransactionsForPeriod, getTransactionsForPeriod,
computeStats, computeStats,
@@ -69,9 +70,14 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
} }
// Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+) // Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+)
// Pour les membres WoT : via keyMap (genesis key = _id Cesium+)
// Pour les non-membres : conversion directe SS58 → Duniter key
const resolveKey = (ss58: string): string =>
keyMap.get(ss58) ?? ss58ToDuniterKey(ss58);
const allDuniterKeys = [...new Set([ const allDuniterKeys = [...new Set([
...rawTransfers.map((t) => keyMap.get(t.fromId)), ...rawTransfers.map((t) => t.fromId ? resolveKey(t.fromId) : undefined),
...rawTransfers.map((t) => keyMap.get(t.toId)), ...rawTransfers.map((t) => t.toId ? resolveKey(t.toId) : undefined),
].filter(Boolean) as string[])]; ].filter(Boolean) as string[])];
// Résolution géo par clé Duniter (_id Cesium+) // Résolution géo par clé Duniter (_id Cesium+)
@@ -89,7 +95,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
const arcs: TransactionArc[] = []; const arcs: TransactionArc[] = [];
for (const t of rawTransfers) { for (const t of rawTransfers) {
const fromDuniterKey = keyMap.get(t.fromId); const fromDuniterKey = t.fromId ? resolveKey(t.fromId) : undefined;
if (!fromDuniterKey) continue; if (!fromDuniterKey) continue;
const fromGeo = geoMap.get(fromDuniterKey); const fromGeo = geoMap.get(fromDuniterKey);
if (!fromGeo) continue; if (!fromGeo) continue;
@@ -107,10 +113,12 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
countryCode: fromGeo.countryCode, countryCode: fromGeo.countryCode,
fromKey: t.fromId, fromKey: t.fromId,
toKey: t.toId, toKey: t.toId,
comment: t.comment,
category: parseComment(t.comment),
}); });
// Arc : les deux extrémités géolocalisées + villes différentes // Arc : les deux extrémités géolocalisées + villes différentes
const toDuniterKey = keyMap.get(t.toId); const toDuniterKey = t.toId ? resolveKey(t.toId) : undefined;
if (!toDuniterKey) continue; if (!toDuniterKey) continue;
const toGeo = geoMap.get(toDuniterKey); const toGeo = geoMap.get(toDuniterKey);
if (!toGeo) continue; if (!toGeo) continue;
@@ -128,12 +136,59 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
toLat: toGeo.lat, toLng: toGeo.lng, toLat: toGeo.lat, toLng: toGeo.lng,
toCity, toCountry: toGeo.countryCode, toCity, toCountry: toGeo.countryCode,
toKey: t.toId, toKey: t.toId,
comment: t.comment,
category: parseComment(t.comment),
}); });
} }
return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) }; return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
} }
// ---------------------------------------------------------------------------
// Vue dividende universel : membres actifs géolocalisés par ville
// ---------------------------------------------------------------------------
export interface MemberCity {
city: string;
lat: number;
lng: number;
count: number;
countryCode: string;
}
let memberCitiesCache: { data: MemberCity[]; expiresAt: number } | null = null;
/**
* Retourne la liste des villes avec le nombre de membres WoT actifs géolocalisés.
* Résultat mis en cache 1 heure (le nombre de membres évolue lentement).
* Traite les ~7000 clés en lots de 500 pour ne pas surcharger Cesium+.
*/
export async function fetchMemberCities(): Promise<MemberCity[]> {
if (memberCitiesCache && Date.now() < memberCitiesCache.expiresAt) return memberCitiesCache.data;
const duniterKeys = await fetchActiveMemberKeys();
const unique = [...new Set(duniterKeys)];
const geoMap = await resolveGeoByKeysBatched(unique);
const cityMap = new Map<string, { lat: number; lng: number; count: number; countryCode: string }>();
for (const geo of geoMap.values()) {
const city = cleanCityName(geo.city);
const existing = cityMap.get(city);
if (existing) {
existing.count++;
} else {
cityMap.set(city, { lat: geo.lat, lng: geo.lng, count: 1, countryCode: geo.countryCode });
}
}
const data: MemberCity[] = [...cityMap.entries()]
.map(([city, v]) => ({ city, ...v }))
.sort((a, b) => b.count - a.count);
memberCitiesCache = { data, expiresAt: Date.now() + 60 * 60 * 1000 };
return data;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -142,6 +197,8 @@ export interface PeriodStats {
transactionCount: number; // total blockchain (y compris non-géolocalisés) transactionCount: number; // total blockchain (y compris non-géolocalisés)
geoCount: number; // transactions visibles sur la carte geoCount: number; // transactions visibles sur la carte
topCities: { name: string; volume: number; count: number; countryCode: string }[]; topCities: { name: string; volume: number; count: number; countryCode: string }[];
categoryBreakdown: { category: import('../data/commentParser').TxCategory; count: number; volume: number }[];
commentedCount: number; // nb de transactions avec un commentaire
} }
export interface DataResult { export interface DataResult {
@@ -183,6 +240,8 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
transactionCount: totalCount, transactionCount: totalCount,
geoCount: geolocated.length, geoCount: geolocated.length,
topCities: base.topCities, topCities: base.topCities,
categoryBreakdown: base.categoryBreakdown,
commentedCount: base.commentedCount,
}, },
source: 'live', source: 'live',
currentUD, currentUD,
+35
View File
@@ -0,0 +1,35 @@
const STORAGE_KEY = {
subsquid: 'geoflux-ep-subsquid',
cesium: 'geoflux-ep-cesium',
} as const;
export const DEFAULT_ENDPOINTS = {
subsquid: 'https://squidv2s.syoul.fr/v1/graphql',
cesium: 'https://g1.data.e-is.pro',
} as const;
export const KNOWN_SUBSQUID_NODES: { label: string; url: string }[] = [
{ label: 'squidv2s.syoul.fr (défaut)', url: 'https://squidv2s.syoul.fr/v1/graphql' },
];
export const KNOWN_CESIUM_NODES: { label: string; url: string }[] = [
{ label: 'g1.data.e-is.pro (défaut)', url: 'https://g1.data.e-is.pro' },
];
export function getSubsquidUrl(): string {
return localStorage.getItem(STORAGE_KEY.subsquid) ?? DEFAULT_ENDPOINTS.subsquid;
}
export function getCesiumUrl(): string {
return localStorage.getItem(STORAGE_KEY.cesium) ?? DEFAULT_ENDPOINTS.cesium;
}
export function setSubsquidUrl(url: string): void {
if (url === DEFAULT_ENDPOINTS.subsquid) localStorage.removeItem(STORAGE_KEY.subsquid);
else localStorage.setItem(STORAGE_KEY.subsquid, url);
}
export function setCesiumUrl(url: string): void {
if (url === DEFAULT_ENDPOINTS.cesium) localStorage.removeItem(STORAGE_KEY.cesium);
else localStorage.setItem(STORAGE_KEY.cesium, url);
}
+62
View File
@@ -0,0 +1,62 @@
const DUNITER_RPC = 'https://rpc.duniter.org';
const CACHE_KEY = 'geoflux-peers-v1';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
interface PeerCache {
urls: string[];
fetchedAt: number;
}
function normalizeSquidUrl(raw: string): string {
const url = raw.replace(/\/$/, '');
return url.endsWith('/v1/graphql') ? url : `${url}/v1/graphql`;
}
export async function discoverSquidNodes(): Promise<string[]> {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const parsed: PeerCache = JSON.parse(cached);
if (Date.now() - parsed.fetchedAt < CACHE_TTL_MS) return parsed.urls;
}
} catch { /* ignore */ }
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8_000);
try {
const res = await fetch(DUNITER_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'duniter_peerings', params: [], id: 1 }),
signal: controller.signal,
});
const data = await res.json();
const peers: { peer_id: string; endpoints: { protocol: string; address: string }[] }[] =
data?.result?.peerings ?? [];
const seen = new Set<string>();
const urls: string[] = [];
for (const peer of peers) {
for (const ep of peer.endpoints ?? []) {
if (ep.protocol === 'squid' && ep.address) {
const normalized = normalizeSquidUrl(ep.address);
if (!seen.has(normalized)) {
seen.add(normalized);
urls.push(normalized);
}
}
}
}
localStorage.setItem(CACHE_KEY, JSON.stringify({ urls, fetchedAt: Date.now() }));
return urls;
} catch {
return [];
} finally {
clearTimeout(timer);
}
}
export function clearPeerCache(): void {
localStorage.removeItem(CACHE_KEY);
}
+20 -2
View File
@@ -12,6 +12,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { getCesiumUrl } from '../EndpointConfig';
export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro'; export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro';
@@ -136,7 +137,7 @@ export async function resolveGeoByKeys(
_source: ['title', 'city', 'geoPoint'], _source: ['title', 'city', 'geoPoint'],
}; };
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, { const response = await fetch(`${getCesiumUrl()}/user/profile/_search`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query), body: JSON.stringify(query),
@@ -163,6 +164,23 @@ export async function resolveGeoByKeys(
return result; return result;
} }
/**
* Même que resolveGeoByKeys mais traite les grands tableaux par lots.
* Nécessaire pour les 6000+ membres actifs (évite des requêtes ES trop grandes).
*/
export async function resolveGeoByKeysBatched(
duniterKeys: string[],
batchSize = 500,
): Promise<Map<string, GeoProfile>> {
const result = new Map<string, GeoProfile>();
for (let i = 0; i < duniterKeys.length; i += batchSize) {
const batch = duniterKeys.slice(i, i + batchSize);
const partial = await resolveGeoByKeys(batch);
for (const [k, v] of partial) result.set(k, v);
}
return result;
}
/** /**
* Résout les coordonnées de plusieurs membres Ğ1 par leur nom d'identité. * Résout les coordonnées de plusieurs membres Ğ1 par leur nom d'identité.
* Envoie une requête Elasticsearch multi-terms en un seul appel. * Envoie une requête Elasticsearch multi-terms en un seul appel.
@@ -193,7 +211,7 @@ export async function resolveGeoByNames(
_source: ['title', 'city', 'geoPoint'], _source: ['title', 'city', 'geoPoint'],
}; };
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, { const response = await fetch(`${getCesiumUrl()}/user/profile/_search`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query), body: JSON.stringify(query),
+45 -3
View File
@@ -12,6 +12,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { getSubsquidUrl } from '../EndpointConfig';
export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql'; export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
@@ -28,6 +29,7 @@ const SubsquidTransferNodeSchema = z.object({
from: z.object({ from: z.object({
linkedIdentity: z.object({ name: z.string() }).nullable(), linkedIdentity: z.object({ name: z.string() }).nullable(),
}).nullable(), }).nullable(),
comment: z.object({ remark: z.string() }).nullable().optional(),
}); });
const SubsquidResponseSchema = z.object({ const SubsquidResponseSchema = z.object({
@@ -51,6 +53,7 @@ export interface RawTransfer {
fromId: string; fromId: string;
toId: string; toId: string;
fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide) fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide)
comment: string | null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -76,6 +79,9 @@ const TRANSFERS_QUERY = `
name name
} }
} }
comment {
remark
}
} }
} }
} }
@@ -136,7 +142,7 @@ const IDENTITY_KEY_MAP_QUERY = `
* car previousId = clé génesis = clé Ed25519 v1 = _id dans Cesium+ * car previousId = clé génesis = clé Ed25519 v1 = _id dans Cesium+
*/ */
export async function buildIdentityKeyMap(): Promise<Map<string, string>> { export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
const response = await fetch(SUBSQUID_ENDPOINT, { const response = await fetch(getSubsquidUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: IDENTITY_KEY_MAP_QUERY }), body: JSON.stringify({ query: IDENTITY_KEY_MAP_QUERY }),
@@ -157,7 +163,7 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
export async function fetchCurrentUD(): Promise<number> { export async function fetchCurrentUD(): Promise<number> {
const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue
try { try {
const response = await fetch(SUBSQUID_ENDPOINT, { const response = await fetch(getSubsquidUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -174,6 +180,41 @@ export async function fetchCurrentUD(): Promise<number> {
} }
} }
// ---------------------------------------------------------------------------
// Membres actifs WoT (isMember = true)
// ---------------------------------------------------------------------------
const ACTIVE_MEMBERS_QUERY = `
query {
identities(filter: { isMember: { equalTo: true } }, first: 20000) {
nodes {
accountId
ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) {
nodes { previousId }
}
}
}
}
`;
/** Retourne la liste des clés SS58 de tous les membres WoT actifs. */
export async function fetchActiveMemberKeys(): Promise<string[]> {
const res = await fetch(getSubsquidUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: ACTIVE_MEMBERS_QUERY }),
});
if (!res.ok) throw new Error(`Subsquid HTTP ${res.status}`);
const raw = await res.json();
if (raw.errors?.length) throw new Error(raw.errors[0].message);
return (raw.data.identities.nodes as { accountId: string; ownerKeyChange: { nodes: { previousId: string }[] } }[])
.map((node) => {
const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId;
return ss58ToDuniterKey(genesisKey);
});
}
export interface FetchTransfersResult { export interface FetchTransfersResult {
transfers: RawTransfer[]; transfers: RawTransfer[];
totalCount: number; totalCount: number;
@@ -187,7 +228,7 @@ export async function fetchTransfers(
Date.now() - periodDays * 24 * 60 * 60 * 1000 Date.now() - periodDays * 24 * 60 * 60 * 1000
).toISOString(); ).toISOString();
const response = await fetch(SUBSQUID_ENDPOINT, { const response = await fetch(getSubsquidUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -217,6 +258,7 @@ export async function fetchTransfers(
fromId: node.fromId ?? '', fromId: node.fromId ?? '',
toId: node.toId ?? '', toId: node.toId ?? '',
fromName: node.from?.linkedIdentity?.name ?? '', fromName: node.from?.linkedIdentity?.name ?? '',
comment: node.comment?.remark ?? null,
})), })),
}; };
} }