9 Commits

Author SHA1 Message Date
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
17 changed files with 1110 additions and 30 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "g1flux",
"private": true,
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"scripts": {
"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
+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*
+86 -7
View File
@@ -4,8 +4,9 @@ import { PeriodSelector } from './components/PeriodSelector';
import { HeatMap } from './components/HeatMap';
import { FlowMap } from './components/FlowMap';
import { AnimationPlayer } from './components/AnimationPlayer';
import { fetchData } from './services/DataService';
import type { PeriodStats } from './services/DataService';
import { SearchBar } from './components/SearchBar';
import { fetchData, fetchMemberCities } from './services/DataService';
import type { PeriodStats, MemberCity } from './services/DataService';
import type { Transaction } from './data/mockData';
import type { TransactionArc } from './data/arcData';
import { computeStats } from './data/mockData';
@@ -13,9 +14,10 @@ import { computeFlowStats } from './data/arcData';
import { useAnimation } from './hooks/useAnimation';
import { useMediaQuery } from './hooks/useMediaQuery';
import { InfoPanel } from './components/InfoPanel';
import { initialUrlState, useUrlSync } from './hooks/useUrlState';
export default function App() {
const [periodDays, setPeriodDays] = useState(7);
const [periodDays, setPeriodDays] = useState(initialUrlState.period);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [arcs, setArcs] = useState<TransactionArc[]>([]);
const [stats, setStats] = useState<PeriodStats | null>(null);
@@ -25,14 +27,36 @@ export default function App() {
const [source, setSource] = useState<'live' | 'mock'>('mock');
const [currentUD, setCurrentUD] = useState<number>(11.78);
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
const [focusCity, setFocusCity] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>(initialUrlState.view);
const [focusCity, setFocusCity] = useState<string | null>(initialUrlState.city);
const [panelOpen, setPanelOpen] = 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 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);
// Synchronise l'état dans l'URL (deep link / partage)
useUrlSync(periodDays, viewMode, focusCity);
const handlePeriodChange = (days: number) => {
animation.deactivate();
setPeriodDays(days);
@@ -43,6 +67,31 @@ export default function App() {
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(() => {
let cancelled = false;
@@ -71,7 +120,7 @@ export default function App() {
const interval = setInterval(() => load(false), 30_000);
return () => { cancelled = true; clearInterval(interval); };
}, [periodDays]);
}, [periodDays, endpointVersion]);
// Stats heatmap sur la fenêtre courante en mode animation
const visibleStats: PeriodStats | null = animation.active
@@ -101,6 +150,8 @@ export default function App() {
viewMode,
flowStats,
focusCity,
allTimestamps,
onEndpointChange: () => setEndpointVersion((v) => v + 1),
};
return (
@@ -111,7 +162,10 @@ export default function App() {
{/* Map area */}
<div className="relative flex-1 min-w-0">
{viewMode === 'heatmap' ? (
<HeatMap transactions={animation.visibleTransactions} />
<HeatMap
transactions={animation.visibleTransactions}
memberCities={showMembers ? memberCities : []}
/>
) : (
<FlowMap
arcs={animation.active ? animation.visibleArcs : arcs}
@@ -140,6 +194,31 @@ export default function App() {
</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 */}
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
<PeriodSelector
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import {
KNOWN_SUBSQUID_NODES,
KNOWN_CESIUM_NODES,
getSubsquidUrl,
getCesiumUrl,
setSubsquidUrl,
setCesiumUrl,
} from '../services/EndpointConfig';
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 runTest = 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 }));
}
};
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>;
};
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">
{/* 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 */}
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nœuds connus</p>
{knownNodes.map((node) => {
const result = testResults.get(node.url);
const isActive = inputUrl === node.url;
return (
<div
key={node.url}
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(node.url)}
>
<div className="min-w-0">
<p className="text-white text-sm font-medium truncate">{node.label}</p>
<p className="text-[#4b5563] text-xs font-mono truncate">{node.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(); runTest(node.url); }}
className="text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
>
Tester
</button>
</div>
</div>
);
})}
</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={() => runTest(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());
if (!result || knownNodes.some((n) => n.url === inputUrl.trim())) 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
);
}
+30 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet.heat';
import type { Transaction } from '../data/mockData';
import type { MemberCity } from '../services/DataService';
// Leaflet default marker fix (Vite asset pipeline)
import iconUrl from 'leaflet/dist/images/marker-icon.png';
@@ -10,6 +11,7 @@ L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl });
interface HeatMapProps {
transactions: Transaction[];
memberCities?: MemberCity[];
}
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 mapRef = useRef<L.Map | 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.
// The canvas opacity is NEVER touched — it stays at leaflet's default.
const prevRef = useRef<HTMLImageElement | null>(null);
@@ -59,9 +62,11 @@ export function HeatMap({ transactions }: HeatMapProps) {
L.control.zoom({ position: 'bottomright' }).addTo(map);
const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map);
const memberLayer = L.layerGroup().addTo(map);
mapRef.current = map;
heatRef.current = heat;
memberLayerRef.current = memberLayer;
// Pendant zoom/pan : cache les overlays → le canvas live est visible directement.
// Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné.
@@ -100,9 +105,33 @@ export function HeatMap({ transactions }: HeatMapProps) {
map.remove();
mapRef.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.
// Canvas is never hidden — we only read its pixel data via toDataURL().
useEffect(() => {
+48 -1
View File
@@ -83,11 +83,42 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
</Feature>
</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">
<Feature icon="📊" name="Panneau latéral">
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.
</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">
Le panneau est accessible via le bouton <Kbd>☰</Kbd> en haut à gauche.
</Feature>
@@ -96,9 +127,25 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
</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">
<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>
</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>
);
}
+19 -4
View File
@@ -1,6 +1,9 @@
import { useRef } from 'react';
import type { PeriodStats } from '../services/DataService';
import type { FlowStats } from '../data/arcData';
import { Sparkline } from './Sparkline';
import { ServiceStatusDots } from './ServiceStatusDots';
import { useServiceStatus } from '../hooks/useServiceStatus';
interface StatsPanelProps {
stats: PeriodStats | null;
@@ -14,6 +17,8 @@ interface StatsPanelProps {
flowStats?: FlowStats | null;
focusCity?: string | null;
onClose?: () => void;
onEndpointChange?: () => void;
allTimestamps?: number[];
}
const MEDALS = ['🥇', '🥈', '🥉'];
@@ -60,7 +65,8 @@ 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 = [] }: StatsPanelProps) {
const { subsquid, cesium, recheck } = useServiceStatus();
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
@@ -95,7 +101,11 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
Ğ1Flux
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
</h1>
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
<ServiceStatusDots
subsquid={subsquid}
cesium={cesium}
onEndpointChange={() => { recheck(); onEndpointChange?.(); }}
/>
</div>
{onClose && (
<button
@@ -113,13 +123,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.
</p>
{/* Period label */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
{/* Period label + sparkline */}
<div className="border-t border-[#1e1f2a] pt-3 space-y-2">
<p className="text-[#4b5563] text-xs">
{animationLabel
? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></>
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
}
</p>
{!animationLabel && allTimestamps.length > 0 && (
<Sparkline timestamps={allTimestamps} periodDays={periodDays} />
)}
</div>
{/* ---- Vue HEATMAP ---- */}
{viewMode === 'heatmap' && (
+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 = 30_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]);
}
+56 -6
View File
@@ -12,8 +12,8 @@
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter';
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
import {
getTransactionsForPeriod,
computeStats,
@@ -69,9 +69,14 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
}
// 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([
...rawTransfers.map((t) => keyMap.get(t.fromId)),
...rawTransfers.map((t) => keyMap.get(t.toId)),
...rawTransfers.map((t) => t.fromId ? resolveKey(t.fromId) : undefined),
...rawTransfers.map((t) => t.toId ? resolveKey(t.toId) : undefined),
].filter(Boolean) as string[])];
// Résolution géo par clé Duniter (_id Cesium+)
@@ -89,7 +94,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
const arcs: TransactionArc[] = [];
for (const t of rawTransfers) {
const fromDuniterKey = keyMap.get(t.fromId);
const fromDuniterKey = t.fromId ? resolveKey(t.fromId) : undefined;
if (!fromDuniterKey) continue;
const fromGeo = geoMap.get(fromDuniterKey);
if (!fromGeo) continue;
@@ -110,7 +115,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
});
// 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;
const toGeo = geoMap.get(toDuniterKey);
if (!toGeo) continue;
@@ -134,6 +139,51 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
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
// ---------------------------------------------------------------------------
+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);
}
+20 -2
View File
@@ -12,6 +12,7 @@
*/
import { z } from 'zod';
import { getCesiumUrl } from '../EndpointConfig';
export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro';
@@ -136,7 +137,7 @@ export async function resolveGeoByKeys(
_source: ['title', 'city', 'geoPoint'],
};
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, {
const response = await fetch(`${getCesiumUrl()}/user/profile/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query),
@@ -163,6 +164,23 @@ export async function resolveGeoByKeys(
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é.
* Envoie une requête Elasticsearch multi-terms en un seul appel.
@@ -193,7 +211,7 @@ export async function resolveGeoByNames(
_source: ['title', 'city', 'geoPoint'],
};
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, {
const response = await fetch(`${getCesiumUrl()}/user/profile/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query),
+39 -3
View File
@@ -12,6 +12,7 @@
*/
import { z } from 'zod';
import { getSubsquidUrl } from '../EndpointConfig';
export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
@@ -136,7 +137,7 @@ const IDENTITY_KEY_MAP_QUERY = `
* car previousId = clé génesis = clé Ed25519 v1 = _id dans Cesium+
*/
export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
const response = await fetch(SUBSQUID_ENDPOINT, {
const response = await fetch(getSubsquidUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: IDENTITY_KEY_MAP_QUERY }),
@@ -157,7 +158,7 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
export async function fetchCurrentUD(): Promise<number> {
const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue
try {
const response = await fetch(SUBSQUID_ENDPOINT, {
const response = await fetch(getSubsquidUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -174,6 +175,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 {
transfers: RawTransfer[];
totalCount: number;
@@ -187,7 +223,7 @@ export async function fetchTransfers(
Date.now() - periodDays * 24 * 60 * 60 * 1000
).toISOString();
const response = await fetch(SUBSQUID_ENDPOINT, {
const response = await fetch(getSubsquidUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({