Compare commits
43 Commits
v1.2.0
...
96ee4a2382
| Author | SHA1 | Date | |
|---|---|---|---|
| 96ee4a2382 | |||
| 6b7591db32 | |||
| 0d9415ae6a | |||
| 7c9d626b98 | |||
| 0136ff9ce1 | |||
| 575ca7a1fc | |||
| ac168c3689 | |||
| 8f9a11c4e8 | |||
| 63f50d5762 | |||
| 9f3752b621 | |||
| 6fc1705f6d | |||
| 15807c7bcb | |||
| bac113e51b | |||
| 6d01c8d29e | |||
| 46b11710cc | |||
| 78ede01d11 | |||
| 70de7e4c06 | |||
| 65f26e2b58 | |||
| 104949427c | |||
| 9ec95dfc91 | |||
| ab1bad2209 | |||
| 3dbd8704ff | |||
| b0104207c4 | |||
| 00f0602c61 | |||
| 4821dab6e6 | |||
| eb4e693f3c | |||
| 810c815706 | |||
| 0e040510af | |||
| ac974fb8a0 | |||
| 666dc99989 | |||
| c02f207b6c | |||
| 77f5f44758 | |||
| c54e76bb04 | |||
| b7e8bade97 | |||
| 64682ea773 | |||
| 53b1e9b399 | |||
| c51bb251e3 | |||
| 851dc46394 | |||
| 786bf30a7b | |||
| 839acf8aa8 | |||
| ffe09ea44a | |||
| e4eb02560a | |||
| 5978ddfed3 |
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
docs-plan
|
||||||
|
docs-bugs
|
||||||
|
docs-syoul
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
+6
-4
@@ -45,20 +45,22 @@ steps:
|
|||||||
|
|
||||||
# Etape 4a : Generation SBOM (Syft) depuis l'image locale
|
# Etape 4a : Generation SBOM (Syft) depuis l'image locale
|
||||||
# NOTE: volumes + pas de from_secret : compatible
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
# Utilise l'image officielle anchore/syft pour eviter le bug d'auto-detection
|
||||||
|
# de container (signal Go imprime en adresse memoire sur alpine + curl install)
|
||||||
- name: sbom-generate
|
- name: sbom-generate
|
||||||
image: alpine:3.20
|
image: alpine:3.20
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
commands:
|
commands:
|
||||||
- apk add --no-cache curl
|
- apk add --no-cache curl tar
|
||||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
- curl -sSfL "https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_linux_amd64.tar.gz" | tar xz -C /usr/local/bin syft
|
||||||
- mkdir -p .reports
|
- mkdir -p .reports
|
||||||
- syft g1flux:latest -o cyclonedx-json --file .reports/sbom-app.cyclonedx.json
|
- syft packages docker:g1flux:latest -o cyclonedx-json=.reports/sbom-app.cyclonedx.json
|
||||||
- echo "SBOM genere"
|
- echo "SBOM genere"
|
||||||
|
|
||||||
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
|
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
|
||||||
- name: sbom-scan
|
- name: sbom-scan
|
||||||
image: aquasec/trivy:latest
|
image: aquasec/trivy:0.69.3
|
||||||
volumes:
|
volumes:
|
||||||
- /home/syoul/trivy-cache:/root/.cache/trivy
|
- /home/syoul/trivy-cache:/root/.cache/trivy
|
||||||
commands:
|
commands:
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "g1flux",
|
"name": "g1flux",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.4.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 1–3)**
|
||||||
|
|
||||||
|
- 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*
|
||||||
+104
-9
@@ -4,17 +4,20 @@ 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';
|
||||||
import { computeFlowStats } from './data/arcData';
|
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 { 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);
|
||||||
@@ -24,13 +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 [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);
|
||||||
@@ -41,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;
|
||||||
|
|
||||||
@@ -69,7 +120,7 @@ export default function App() {
|
|||||||
const interval = setInterval(() => load(false), 30_000);
|
const interval = setInterval(() => load(false), 30_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
|
||||||
@@ -99,6 +150,8 @@ export default function App() {
|
|||||||
viewMode,
|
viewMode,
|
||||||
flowStats,
|
flowStats,
|
||||||
focusCity,
|
focusCity,
|
||||||
|
allTimestamps,
|
||||||
|
onEndpointChange: () => setEndpointVersion((v) => v + 1),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -109,7 +162,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}
|
||||||
@@ -129,6 +185,40 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bouton info — sous ☰ sur mobile, top-left sur desktop */}
|
||||||
|
<button
|
||||||
|
onClick={() => setInfoOpen(true)}
|
||||||
|
className={`absolute ${isMobile ? 'top-16' : 'top-4'} left-4 z-[1001] w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#6b7280] hover:text-[#d4a843] transition-colors text-base`}
|
||||||
|
aria-label="Aide"
|
||||||
|
>
|
||||||
|
ℹ
|
||||||
|
</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
|
||||||
@@ -138,6 +228,9 @@ export default function App() {
|
|||||||
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={handleViewModeChange}
|
onViewModeChange={handleViewModeChange}
|
||||||
|
geoPercent={visibleStats && visibleStats.transactionCount > 0
|
||||||
|
? Math.round((visibleStats.geoCount / visibleStats.transactionCount) * 100)
|
||||||
|
: null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,7 +271,6 @@ export default function App() {
|
|||||||
onPlay={animation.play}
|
onPlay={animation.play}
|
||||||
onPause={animation.pause}
|
onPause={animation.pause}
|
||||||
onSpeedChange={animation.setSpeed}
|
onSpeedChange={animation.setSpeed}
|
||||||
onClose={animation.deactivate}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -193,6 +285,9 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Info panel */}
|
||||||
|
{infoOpen && <InfoPanel onClose={() => setInfoOpen(false)} />}
|
||||||
|
|
||||||
{/* Bottom drawer — mobile uniquement */}
|
{/* Bottom drawer — mobile uniquement */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<>
|
<>
|
||||||
@@ -210,7 +305,7 @@ export default function App() {
|
|||||||
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
|
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
|
||||||
<div className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
|
<div className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
|
||||||
</div>
|
</div>
|
||||||
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} />
|
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} className="w-full flex-1 min-h-0" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ interface AnimationPlayerProps {
|
|||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onPause: () => void;
|
onPause: () => void;
|
||||||
onSpeedChange: (s: 1 | 2 | 4) => void;
|
onSpeedChange: (s: 1 | 2 | 4) => void;
|
||||||
onClose: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimationPlayer({
|
export function AnimationPlayer({
|
||||||
@@ -21,7 +20,6 @@ export function AnimationPlayer({
|
|||||||
onPlay,
|
onPlay,
|
||||||
onPause,
|
onPause,
|
||||||
onSpeedChange,
|
onSpeedChange,
|
||||||
onClose,
|
|
||||||
}: AnimationPlayerProps) {
|
}: AnimationPlayerProps) {
|
||||||
const frame = frames[currentIndex];
|
const frame = frames[currentIndex];
|
||||||
|
|
||||||
@@ -78,8 +76,7 @@ export function AnimationPlayer({
|
|||||||
|
|
||||||
{/* Speed selector */}
|
{/* Speed selector */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
|
{([1, 2, 4] as const).map((s) => (
|
||||||
{([1, 2, 4] as const).map((s) => (
|
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => onSpeedChange(s)}
|
onClick={() => onSpeedChange(s)}
|
||||||
@@ -94,14 +91,6 @@ export function AnimationPlayer({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Close */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-[#4b5563] hover:text-white transition-colors px-2 py-1 text-sm ml-2"
|
|
||||||
title="Quitter l'animation"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
+83
-20
@@ -17,7 +17,7 @@ function lerpColor(hex1: string, hex2: string, t: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
|
const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
|
||||||
const COLOR_NEG = '#ff6d00'; // orange vif
|
const COLOR_NEG = '#e53935'; // rouge vif
|
||||||
const COLOR_POS = '#00c853'; // vert vif
|
const COLOR_POS = '#00c853'; // vert vif
|
||||||
const NEUTRAL_THRESHOLD = 0.05; // ±5 % → couleur neutre
|
const NEUTRAL_THRESHOLD = 0.05; // ±5 % → couleur neutre
|
||||||
const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux villes
|
const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux villes
|
||||||
@@ -39,8 +39,9 @@ interface FlowMapProps {
|
|||||||
export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||||
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 [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const [tick, setTick] = useState(0); // incrémenté sur moveend/zoomend → re-render
|
const [tick, setTick] = useState(0);
|
||||||
|
const [clustered, setClustered] = useState(true);
|
||||||
|
|
||||||
// Initialisation Leaflet
|
// Initialisation Leaflet
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,33 +115,35 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
return { name, lat: d.lat, lng: d.lng, x: p.x, y: p.y, emitted: d.emitted, received: d.received, vol: d.emitted + d.received };
|
return { name, lat: d.lat, lng: d.lng, x: p.x, y: p.y, emitted: d.emitted, received: d.received, vol: d.emitted + d.received };
|
||||||
}).sort((a, b) => b.vol - a.vol);
|
}).sort((a, b) => b.vol - a.vol);
|
||||||
|
|
||||||
// --- 2. Clustering glouton par distance pixel ---
|
// --- 2. Clustering glouton par distance pixel (ou 1 ville = 1 cluster) ---
|
||||||
interface Cluster {
|
interface Cluster {
|
||||||
cx: number; cy: number; // centroïde pondéré (pixels)
|
cx: number; cy: number;
|
||||||
lat: number; lng: number; // centroïde géo (pour debug éventuel)
|
lat: number; lng: number;
|
||||||
totalVol: number;
|
totalVol: number;
|
||||||
emitted: number; received: number;
|
emitted: number; received: number;
|
||||||
cities: Set<string>;
|
cities: Set<string>;
|
||||||
}
|
}
|
||||||
const clusters: Cluster[] = [];
|
const clusters: Cluster[] = [];
|
||||||
const cityClusterIdx = new Map<string, number>(); // nom ville → index cluster
|
const cityClusterIdx = new Map<string, number>();
|
||||||
|
|
||||||
for (const city of cityList) {
|
for (const city of cityList) {
|
||||||
let bestIdx = -1;
|
let bestIdx = -1;
|
||||||
let bestDist = Infinity;
|
|
||||||
for (let i = 0; i < clusters.length; i++) {
|
if (clustered) {
|
||||||
const cl = clusters[i];
|
let bestDist = Infinity;
|
||||||
const dx = city.x - cl.cx;
|
for (let i = 0; i < clusters.length; i++) {
|
||||||
const dy = city.y - cl.cy;
|
const cl = clusters[i];
|
||||||
const d = Math.sqrt(dx * dx + dy * dy);
|
const dx = city.x - cl.cx;
|
||||||
if (d < CLUSTER_RADIUS && d < bestDist) {
|
const dy = city.y - cl.cy;
|
||||||
bestDist = d;
|
const d = Math.sqrt(dx * dx + dy * dy);
|
||||||
bestIdx = i;
|
if (d < CLUSTER_RADIUS && d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
bestIdx = i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestIdx === -1) {
|
if (bestIdx === -1) {
|
||||||
// Nouvelle graine
|
|
||||||
clusters.push({
|
clusters.push({
|
||||||
cx: city.x, cy: city.y,
|
cx: city.x, cy: city.y,
|
||||||
lat: city.lat, lng: city.lng,
|
lat: city.lat, lng: city.lng,
|
||||||
@@ -150,7 +153,6 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
});
|
});
|
||||||
cityClusterIdx.set(city.name, clusters.length - 1);
|
cityClusterIdx.set(city.name, clusters.length - 1);
|
||||||
} else {
|
} else {
|
||||||
// Fusionner dans le cluster existant (centroïde pondéré)
|
|
||||||
const cl = clusters[bestIdx];
|
const cl = clusters[bestIdx];
|
||||||
const newVol = cl.totalVol + city.vol;
|
const newVol = cl.totalVol + city.vol;
|
||||||
cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol;
|
cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol;
|
||||||
@@ -258,15 +260,21 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
return { arcElems, nodeElems };
|
return { arcElems, nodeElems };
|
||||||
// tick en dep pour re-projeter sur pan/zoom
|
// tick en dep pour re-projeter sur pan/zoom
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [corridors, cityNodes, focusCity, tick, mapReady]);
|
}, [corridors, cityNodes, focusCity, tick, mapReady, clustered]);
|
||||||
|
|
||||||
// Handler de clic : on transmet la première ville du cluster cliqué
|
const [popupIdx, setPopupIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Ferme le popup sur déplacement/zoom
|
||||||
|
useEffect(() => { setPopupIdx(null); }, [tick]);
|
||||||
|
|
||||||
|
// Handler de clic : ouvre/ferme le popup + focus
|
||||||
const handleNodeClick = (nodeIdx: number) => {
|
const handleNodeClick = (nodeIdx: number) => {
|
||||||
if (!svgElements) return;
|
if (!svgElements) return;
|
||||||
const node = svgElements.nodeElems[nodeIdx];
|
const node = svgElements.nodeElems[nodeIdx];
|
||||||
const firstCity = [...node.cl.cities][0];
|
const firstCity = [...node.cl.cities][0];
|
||||||
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
|
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
|
||||||
onCityClick(isCurrentFocus ? null : firstCity);
|
onCityClick(isCurrentFocus ? null : firstCity);
|
||||||
|
setPopupIdx(popupIdx === nodeIdx ? null : nodeIdx);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -338,6 +346,61 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bouton cluster / villes */}
|
||||||
|
<button
|
||||||
|
onClick={() => setClustered(c => !c)}
|
||||||
|
className={`absolute bottom-44 sm:bottom-24 left-4 z-[1002] px-3 py-1.5 rounded-lg text-xs font-medium border transition-all duration-200 ${
|
||||||
|
clustered
|
||||||
|
? 'bg-[#d4a843] text-[#0a0b0f] border-[#d4a843] shadow-[0_0_10px_rgba(212,168,67,0.35)]'
|
||||||
|
: 'bg-[#0a0b0f]/90 text-[#6b7280] border-[#2e2f3a] hover:text-[#d4a843] hover:border-[#d4a843]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{clustered ? '⬡ Clusters' : '· Villes'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Popup cluster */}
|
||||||
|
{mapReady && svgElements && popupIdx !== null && (() => {
|
||||||
|
const node = svgElements.nodeElems[popupIdx];
|
||||||
|
const cities = [...node.cl.cities].map(name => {
|
||||||
|
const d = cityNodes.get(name);
|
||||||
|
const net = d ? d.received - d.emitted : 0;
|
||||||
|
return { name, net };
|
||||||
|
}).sort((a, b) => Math.abs(b.net) - Math.abs(a.net));
|
||||||
|
|
||||||
|
// Position : décale à droite du cercle, recadre si hors écran
|
||||||
|
const raw = node.cl.cx + node.r + 8;
|
||||||
|
const containerW = containerRef.current?.clientWidth ?? 0;
|
||||||
|
const popupW = 200;
|
||||||
|
const left = raw + popupW > containerW ? node.cl.cx - node.r - 8 - popupW : raw;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute z-[600] bg-[#0a0b0f]/95 border border-[#2e2f3a] rounded-xl p-3 shadow-xl"
|
||||||
|
style={{ left, top: Math.max(4, node.cl.cy - 80), width: popupW, pointerEvents: 'auto' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[#4b5563] text-[10px] uppercase tracking-widest">
|
||||||
|
{node.cityCount > 1 ? `${node.cityCount} villes` : 'Ville'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPopupIdx(null)}
|
||||||
|
className="text-[#4b5563] hover:text-white text-xs leading-none ml-2"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{cities.map(({ name, net }) => (
|
||||||
|
<div key={name} className="flex items-center justify-between gap-1">
|
||||||
|
<span className="text-white text-xs truncate">{name}</span>
|
||||||
|
<span className={`text-xs font-mono shrink-0 ${net >= 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}>
|
||||||
|
{net >= 0 ? '+' : ''}{Math.round(net).toLocaleString('fr-FR')} Ğ1
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
interface InfoPanelProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoPanel({ onClose }: InfoPanelProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[2000] bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modale */}
|
||||||
|
<div className="fixed z-[2001] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[min(520px,calc(100vw-2rem))] max-h-[80vh] overflow-y-auto rounded-2xl bg-[#0f1016] border border-[#2e2f3a] shadow-2xl">
|
||||||
|
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[#2e2f3a] sticky top-0 bg-[#0f1016] z-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[#d4a843] font-semibold text-base">Ğ1Flux</h2>
|
||||||
|
<p className="text-[#6b7280] text-xs mt-0.5">Explorateur de transactions Ğ1v2</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[#4b5563] hover:text-white text-xl leading-none p-1"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="px-5 py-4 flex flex-col gap-5 text-sm">
|
||||||
|
|
||||||
|
<Section title="Vues cartographiques">
|
||||||
|
<Feature icon="🌡" name="Heatmap">
|
||||||
|
Densité des transactions géolocalisées. Les zones chaudes concentrent le plus d'activité.
|
||||||
|
Basculer avec le bouton <Kbd>Heatmap / Flux</Kbd>.
|
||||||
|
</Feature>
|
||||||
|
<Feature icon="⟿" name="Flux">
|
||||||
|
Arcs entre villes représentant les flux de Ğ1. L'épaisseur indique le volume,
|
||||||
|
la couleur la direction dominante.
|
||||||
|
</Feature>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Clusters & villes (vue Flux)">
|
||||||
|
<Feature icon="⬡" name="Mode Clusters">
|
||||||
|
Les villes géographiquement proches sont regroupées en un nœud unique.
|
||||||
|
Le chiffre affiché indique le nombre de villes dans le groupe.
|
||||||
|
</Feature>
|
||||||
|
<Feature icon="·" name="Mode Villes">
|
||||||
|
Chaque ville est affichée individuellement, sans regroupement.
|
||||||
|
Basculer avec le bouton <Kbd>⬡ Clusters / · Villes</Kbd> (bas gauche de la carte).
|
||||||
|
</Feature>
|
||||||
|
<Feature icon="●" name="Couleur des nœuds">
|
||||||
|
<span className="text-green-400">Vert</span> = receveur net (reçoit plus que ce qu'il émet) ·{' '}
|
||||||
|
<span className="text-[#d4a843]">Or</span> = équilibré (dégradé or → vert selon l'excédent reçu) ·{' '}
|
||||||
|
<span className="text-[#e53935]">Rouge</span> = émetteur net (dégradé or → rouge selon l'excédent émis).
|
||||||
|
</Feature>
|
||||||
|
<Feature icon="↗" name="Clic sur un nœud">
|
||||||
|
Affiche la liste des villes du cluster avec leur balance individuelle,
|
||||||
|
triée par valeur absolue.
|
||||||
|
</Feature>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Période">
|
||||||
|
<Feature icon="📅" name="Préréglages">
|
||||||
|
<Kbd>24h</Kbd> <Kbd>7 jours</Kbd> <Kbd>30 jours</Kbd> — fenêtre glissante jusqu'à maintenant.
|
||||||
|
</Feature>
|
||||||
|
<Feature icon="✎" name="Personnaliser">
|
||||||
|
Saisir une durée de 1 à 365 jours.
|
||||||
|
</Feature>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Animation">
|
||||||
|
<Feature icon="▶" name="Animer">
|
||||||
|
Rejoue les transactions frame par frame sur la période sélectionnée
|
||||||
|
(une frame = une journée).
|
||||||
|
</Feature>
|
||||||
|
<Feature icon="⏩" name="Contrôles">
|
||||||
|
Lecture / pause · Navigation frame par frame (<Kbd>◀◀</Kbd> <Kbd>▶▶</Kbd>) ·
|
||||||
|
Vitesse <Kbd>×1</Kbd> <Kbd>×2</Kbd> <Kbd>×4</Kbd>.
|
||||||
|
</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>
|
||||||
|
<Feature icon="%" name="% Tx géoloc.">
|
||||||
|
Pourcentage des transactions ayant une géolocalisation connue sur la période / frame courante.
|
||||||
|
</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 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>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[#d4a843] text-xs font-semibold uppercase tracking-wider mb-2">{title}</h3>
|
||||||
|
<div className="flex flex-col gap-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Feature({ icon, name, children }: { icon: string; name: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-[#4b5563] w-5 shrink-0 text-center leading-5 mt-0.5">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-white font-medium">{name}</span>
|
||||||
|
<span className="text-[#6b7280]"> — </span>
|
||||||
|
<span className="text-[#9ca3af]">{children}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kbd({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-block bg-[#1a1b23] border border-[#2e2f3a] rounded px-1 py-0.5 text-[11px] text-[#d4a843] font-mono leading-none">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ interface PeriodSelectorProps {
|
|||||||
onAnimate: () => void;
|
onAnimate: () => void;
|
||||||
viewMode: 'heatmap' | 'flow';
|
viewMode: 'heatmap' | 'flow';
|
||||||
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
||||||
|
geoPercent?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
@@ -17,7 +18,7 @@ const PERIODS = [
|
|||||||
|
|
||||||
const PRESET_DAYS = new Set([1, 7, 30]);
|
const PRESET_DAYS = new Set([1, 7, 30]);
|
||||||
|
|
||||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange }: PeriodSelectorProps) {
|
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) {
|
||||||
const [customOpen, setCustomOpen] = useState(false);
|
const [customOpen, setCustomOpen] = useState(false);
|
||||||
const [inputVal, setInputVal] = useState('');
|
const [inputVal, setInputVal] = useState('');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -124,6 +125,12 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
|||||||
>
|
>
|
||||||
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
|
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
|
||||||
</button>
|
</button>
|
||||||
|
{geoPercent != null && (
|
||||||
|
<span className="text-[10px] font-mono text-white px-1 shrink-0">
|
||||||
|
{geoPercent}% Tx géoloc.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } 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';
|
||||||
|
|
||||||
interface StatsPanelProps {
|
interface StatsPanelProps {
|
||||||
stats: PeriodStats | null;
|
stats: PeriodStats | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
periodDays: number;
|
periodDays: number;
|
||||||
source: 'live' | 'mock';
|
source: 'live' | 'mock';
|
||||||
|
className?: string;
|
||||||
currentUD: number;
|
currentUD: number;
|
||||||
animationLabel?: string;
|
animationLabel?: string;
|
||||||
viewMode?: 'heatmap' | 'flow';
|
viewMode?: 'heatmap' | 'flow';
|
||||||
flowStats?: FlowStats | null;
|
flowStats?: FlowStats | null;
|
||||||
focusCity?: string | null;
|
focusCity?: string | null;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
onEndpointChange?: () => void;
|
||||||
|
allTimestamps?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
@@ -59,7 +65,8 @@ function CityRow({ city, volume, count, countryCode, accent }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose }: 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 periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||||
const prevStats = useRef<PeriodStats | null>(null);
|
const prevStats = useRef<PeriodStats | null>(null);
|
||||||
|
|
||||||
@@ -83,7 +90,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
if (stats && !loading) prevStats.current = stats;
|
if (stats && !loading) prevStats.current = stats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-full lg:w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto h-full">
|
<aside className={`flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto ${className ?? 'w-72 shrink-0'}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
|
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
|
||||||
@@ -94,7 +101,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
|
||||||
@@ -112,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.
|
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' && (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
* 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 {
|
import {
|
||||||
getTransactionsForPeriod,
|
getTransactionsForPeriod,
|
||||||
computeStats,
|
computeStats,
|
||||||
@@ -69,9 +69,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 +94,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;
|
||||||
@@ -110,7 +115,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -134,6 +139,51 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
|||||||
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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -136,7 +137,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 +158,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 +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 {
|
export interface FetchTransfersResult {
|
||||||
transfers: RawTransfer[];
|
transfers: RawTransfer[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
@@ -187,7 +223,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({
|
||||||
|
|||||||
Reference in New Issue
Block a user