Compare commits
50 Commits
v1.1.0
...
a36a6729e3
| Author | SHA1 | Date | |
|---|---|---|---|
| a36a6729e3 | |||
| 6b42a75140 | |||
| 8e396cd331 | |||
| 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 | |||
| 8e208d02ab | |||
| 16cebb6ec9 | |||
| b6cb0af722 | |||
| 78b4762c88 | |||
| 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
|
||||
# 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
|
||||
image: alpine:3.20
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||
- apk add --no-cache curl tar
|
||||
- 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
|
||||
- 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"
|
||||
|
||||
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
|
||||
- name: sbom-scan
|
||||
image: aquasec/trivy:latest
|
||||
image: aquasec/trivy:0.69.3
|
||||
volumes:
|
||||
- /home/syoul/trivy-cache:/root/.cache/trivy
|
||||
commands:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "g1flux",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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,24 @@
|
||||
## What's Changed
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
- **Nature des échanges** — les commentaires de transactions sont analysés et classés automatiquement en catégories : don & gratitude, alimentation, soin & bien-être, vêtements, culture & loisirs, événement, service & travaux, remboursement, migration, ticket, autre
|
||||
- Distribution des catégories affichée dans le panneau latéral (barres proportionnelles sur les transactions commentées de la période) — chaque catégorie est cliquable pour dérouler la liste des transactions avec leur commentaire et montant
|
||||
- Tooltip au survol des arcs en vue Flux : répartition des catégories + échantillon de commentaires bruts du corridor
|
||||
- 76 % des transactions Ğ1v2 comportent un commentaire — le champ `remark` est désormais fetché depuis SubSquid
|
||||
|
||||
### Améliorations
|
||||
|
||||
- InfoPanel mis à jour : section *Nature des échanges* documentée
|
||||
|
||||
### Détails techniques
|
||||
|
||||
- Nouveau `src/data/commentParser.ts` — ~80 règles regex multilingues (FR/ES/CA/IT/EN/PT), 11 catégories, priorité ordonnée
|
||||
- `SubsquidAdapter` : ajout de `comment { remark }` à la query GraphQL
|
||||
- `Transaction` et `TransactionArc` : nouveaux champs `comment: string | null` et `category: TxCategory`
|
||||
- `Corridor` : nouveaux champs `categories` (agrégées) et `comments` (échantillon jusqu'à 5)
|
||||
- `PeriodStats` : nouveaux champs `categoryBreakdown` et `commentedCount`
|
||||
- Zone de hit des arcs SVG élargie (+8 px) pour faciliter le survol
|
||||
- Aucune nouvelle dépendance npm
|
||||
|
||||
**Full Changelog**: https://git.syoul.fr/geoflux/compare/v1.5.0...v1.6.0
|
||||
@@ -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*
|
||||
+166
-24
@@ -4,16 +4,20 @@ import { PeriodSelector } from './components/PeriodSelector';
|
||||
import { HeatMap } from './components/HeatMap';
|
||||
import { FlowMap } from './components/FlowMap';
|
||||
import { AnimationPlayer } from './components/AnimationPlayer';
|
||||
import { fetchData } from './services/DataService';
|
||||
import type { PeriodStats } from './services/DataService';
|
||||
import { SearchBar } from './components/SearchBar';
|
||||
import { fetchData, fetchMemberCities } from './services/DataService';
|
||||
import type { PeriodStats, MemberCity } from './services/DataService';
|
||||
import type { Transaction } from './data/mockData';
|
||||
import type { TransactionArc } from './data/arcData';
|
||||
import { computeStats } from './data/mockData';
|
||||
import { computeFlowStats } from './data/arcData';
|
||||
import { useAnimation } from './hooks/useAnimation';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import { InfoPanel } from './components/InfoPanel';
|
||||
import { initialUrlState, useUrlSync } from './hooks/useUrlState';
|
||||
|
||||
export default function App() {
|
||||
const [periodDays, setPeriodDays] = useState(7);
|
||||
const [periodDays, setPeriodDays] = useState(initialUrlState.period);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [arcs, setArcs] = useState<TransactionArc[]>([]);
|
||||
const [stats, setStats] = useState<PeriodStats | null>(null);
|
||||
@@ -23,11 +27,36 @@ export default function App() {
|
||||
const [source, setSource] = useState<'live' | 'mock'>('mock');
|
||||
const [currentUD, setCurrentUD] = useState<number>(11.78);
|
||||
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
||||
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
||||
const [focusCity, setFocusCity] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>(initialUrlState.view);
|
||||
const [focusCity, setFocusCity] = useState<string | null>(initialUrlState.city);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [showMembers, setShowMembers] = useState(false);
|
||||
const [memberCities, setMemberCities] = useState<MemberCity[]>([]);
|
||||
const [membersLoading, setMembersLoading] = useState(false);
|
||||
const [endpointVersion, setEndpointVersion] = useState(0);
|
||||
const isMobile = useMediaQuery('(max-width: 639px)');
|
||||
|
||||
const toggleMembers = async () => {
|
||||
if (showMembers) { setShowMembers(false); return; }
|
||||
if (memberCities.length > 0) { setShowMembers(true); return; }
|
||||
setMembersLoading(true);
|
||||
try {
|
||||
const cities = await fetchMemberCities();
|
||||
setMemberCities(cities);
|
||||
setShowMembers(true);
|
||||
} catch (err) {
|
||||
console.warn('fetchMemberCities error:', err);
|
||||
} finally {
|
||||
setMembersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||
|
||||
// Synchronise l'état dans l'URL (deep link / partage)
|
||||
useUrlSync(periodDays, viewMode, focusCity);
|
||||
|
||||
const handlePeriodChange = (days: number) => {
|
||||
animation.deactivate();
|
||||
setPeriodDays(days);
|
||||
@@ -38,6 +67,31 @@ export default function App() {
|
||||
setFocusCity(null);
|
||||
};
|
||||
|
||||
// Raccourcis clavier
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === 'ArrowLeft' && animation.active) {
|
||||
animation.seek(Math.max(0, animation.currentIndex - 1));
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowRight' && animation.active) {
|
||||
animation.seek(Math.min(animation.frames.length - 1, animation.currentIndex + 1));
|
||||
e.preventDefault();
|
||||
} else if (e.key === ' ' && animation.active) {
|
||||
animation.playing ? animation.pause() : animation.play();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
if (infoOpen) { setInfoOpen(false); e.preventDefault(); }
|
||||
else if (animation.active) { animation.deactivate(); e.preventDefault(); }
|
||||
} else if (e.key === 'h' || e.key === 'H') {
|
||||
handleViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animation.active, animation.playing, animation.currentIndex, animation.frames.length, infoOpen, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -63,10 +117,10 @@ export default function App() {
|
||||
};
|
||||
|
||||
load(true);
|
||||
const interval = setInterval(() => load(false), 30_000);
|
||||
const interval = setInterval(() => load(false), 120_000);
|
||||
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [periodDays]);
|
||||
}, [periodDays, endpointVersion]);
|
||||
|
||||
// Stats heatmap sur la fenêtre courante en mode animation
|
||||
const visibleStats: PeriodStats | null = animation.active
|
||||
@@ -86,25 +140,33 @@ export default function App() {
|
||||
[arcs, animation.visibleArcs, animation.active],
|
||||
);
|
||||
|
||||
const statsPanelProps = {
|
||||
stats: visibleStats,
|
||||
loading,
|
||||
periodDays,
|
||||
source,
|
||||
currentUD,
|
||||
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
|
||||
viewMode,
|
||||
flowStats,
|
||||
focusCity,
|
||||
allTimestamps,
|
||||
onEndpointChange: () => setEndpointVersion((v) => v + 1),
|
||||
transactions,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
||||
{/* Side panel */}
|
||||
<StatsPanel
|
||||
stats={visibleStats}
|
||||
loading={loading}
|
||||
periodDays={periodDays}
|
||||
source={source}
|
||||
currentUD={currentUD}
|
||||
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
||||
viewMode={viewMode}
|
||||
flowStats={flowStats}
|
||||
focusCity={focusCity}
|
||||
/>
|
||||
{/* Side panel — desktop uniquement */}
|
||||
{!isMobile && <StatsPanel {...statsPanelProps} />}
|
||||
|
||||
{/* Map area */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{viewMode === 'heatmap' ? (
|
||||
<HeatMap transactions={animation.visibleTransactions} />
|
||||
<HeatMap
|
||||
transactions={animation.visibleTransactions}
|
||||
memberCities={showMembers ? memberCities : []}
|
||||
/>
|
||||
) : (
|
||||
<FlowMap
|
||||
arcs={animation.active ? animation.visibleArcs : arcs}
|
||||
@@ -113,8 +175,53 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bouton menu — mobile uniquement */}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setPanelOpen(true)}
|
||||
className="absolute top-4 left-4 z-[1001] w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#d4a843] text-lg"
|
||||
aria-label="Ouvrir le panneau"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
||||
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
||||
<PeriodSelector
|
||||
value={periodDays}
|
||||
onChange={handlePeriodChange}
|
||||
@@ -122,11 +229,22 @@ export default function App() {
|
||||
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
geoPercent={visibleStats && visibleStats.transactionCount > 0
|
||||
? Math.round((visibleStats.geoCount / visibleStats.transactionCount) * 100)
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transaction count + source badge (masqués en mode animation) */}
|
||||
{!loading && !animation.active && (
|
||||
{/* Badge ville focus — mobile uniquement */}
|
||||
{isMobile && focusCity && (
|
||||
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1001] flex items-center gap-2 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#d4a843]/40 rounded-full px-3 py-1.5">
|
||||
<span className="text-[#d4a843] text-xs font-medium">{focusCity}</span>
|
||||
<button onClick={() => setFocusCity(null)} className="text-[#4b5563] hover:text-white text-xs leading-none">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction count + source badge (masqués sur mobile et en mode animation) */}
|
||||
{!loading && !animation.active && !isMobile && (
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
||||
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
||||
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||
@@ -154,7 +272,6 @@ export default function App() {
|
||||
onPlay={animation.play}
|
||||
onPause={animation.pause}
|
||||
onSpeedChange={animation.setSpeed}
|
||||
onClose={animation.deactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -168,6 +285,31 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info panel */}
|
||||
{infoOpen && <InfoPanel onClose={() => setInfoOpen(false)} />}
|
||||
|
||||
{/* Bottom drawer — mobile uniquement */}
|
||||
{isMobile && (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
{panelOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[1009] bg-black/50"
|
||||
onClick={() => setPanelOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={`fixed bottom-0 left-0 right-0 z-[1010] h-[85vh] flex flex-col transition-transform duration-300 ${panelOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
||||
>
|
||||
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
|
||||
<div className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
|
||||
</div>
|
||||
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} className="w-full flex-1 min-h-0" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ interface AnimationPlayerProps {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSpeedChange: (s: 1 | 2 | 4) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AnimationPlayer({
|
||||
@@ -21,13 +20,12 @@ export function AnimationPlayer({
|
||||
onPlay,
|
||||
onPause,
|
||||
onSpeedChange,
|
||||
onClose,
|
||||
}: AnimationPlayerProps) {
|
||||
const frame = frames[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
|
||||
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,calc(100vw-1rem))]">
|
||||
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-4 py-3 flex flex-col gap-2.5 shadow-xl">
|
||||
|
||||
{/* Frame label + position */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -50,7 +48,7 @@ export function AnimationPlayer({
|
||||
/>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||
|
||||
{/* Playback buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -78,8 +76,7 @@ export function AnimationPlayer({
|
||||
|
||||
{/* Speed selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
|
||||
{([1, 2, 4] as const).map((s) => (
|
||||
{([1, 2, 4] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onSpeedChange(s)}
|
||||
@@ -94,14 +91,6 @@ export function AnimationPlayer({
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
+327
-59
@@ -1,7 +1,31 @@
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import L from 'leaflet';
|
||||
|
||||
/** Interpolation RGB linéaire entre deux couleurs hex, t ∈ [0, 1]. */
|
||||
function lerpColor(hex1: string, hex2: string, t: number): string {
|
||||
const parse = (h: string) => [
|
||||
parseInt(h.slice(1, 3), 16),
|
||||
parseInt(h.slice(3, 5), 16),
|
||||
parseInt(h.slice(5, 7), 16),
|
||||
];
|
||||
const [r1, g1, b1] = parse(hex1);
|
||||
const [r2, g2, b2] = parse(hex2);
|
||||
const r = Math.round(r1 + (r2 - r1) * t);
|
||||
const g = Math.round(g1 + (g2 - g1) * t);
|
||||
const b = Math.round(b1 + (b2 - b1) * t);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
|
||||
const COLOR_NEG = '#e53935'; // rouge vif
|
||||
const COLOR_POS = '#00c853'; // vert vif
|
||||
const NEUTRAL_THRESHOLD = 0.05; // ±5 % → couleur neutre
|
||||
const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux villes
|
||||
|
||||
import type { TransactionArc } from '../data/arcData';
|
||||
import { buildCorridors } from '../data/arcData';
|
||||
import type { TxCategory } from '../data/commentParser';
|
||||
import { CATEGORY_LABELS, CATEGORY_COLORS, aggregateCategories } from '../data/commentParser';
|
||||
|
||||
// Leaflet default marker fix (Vite asset pipeline)
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
@@ -17,8 +41,9 @@ interface FlowMapProps {
|
||||
export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [tick, setTick] = useState(0); // incrémenté sur moveend/zoomend → re-render
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [clustered, setClustered] = useState(true);
|
||||
|
||||
// Initialisation Leaflet
|
||||
useEffect(() => {
|
||||
@@ -71,7 +96,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
return map;
|
||||
}, [corridors]);
|
||||
|
||||
// Projection SVG (recalculée sur chaque tick, changement d'arcs ou de focusCity)
|
||||
// Projection SVG + clustering (recalculée sur chaque tick, changement d'arcs ou de focusCity)
|
||||
const svgElements = useMemo(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m || !mapReady) return null;
|
||||
@@ -81,15 +106,115 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
return { x: p.x, y: p.y };
|
||||
};
|
||||
|
||||
const maxVol = Math.max(...corridors.map(c => c.totalVolume), 1);
|
||||
const maxNodeVol = Math.max(...[...cityNodes.values()].map(c => c.emitted + c.received), 1);
|
||||
// --- 1. Projeter toutes les villes en pixels, triées par volume desc ---
|
||||
type CityPx = {
|
||||
name: string; lat: number; lng: number;
|
||||
x: number; y: number;
|
||||
emitted: number; received: number; vol: number;
|
||||
};
|
||||
const cityList: CityPx[] = [...cityNodes.entries()].map(([name, d]) => {
|
||||
const p = proj(d.lat, d.lng);
|
||||
return { name, lat: d.lat, lng: d.lng, x: p.x, y: p.y, emitted: d.emitted, received: d.received, vol: d.emitted + d.received };
|
||||
}).sort((a, b) => b.vol - a.vol);
|
||||
|
||||
// ---- Arcs ----
|
||||
const arcElems = corridors.map((c, idx) => {
|
||||
const p1 = proj(c.fromLat, c.fromLng);
|
||||
const p2 = proj(c.toLat, c.toLng);
|
||||
// --- 2. Clustering glouton par distance pixel (ou 1 ville = 1 cluster) ---
|
||||
interface Cluster {
|
||||
cx: number; cy: number;
|
||||
lat: number; lng: number;
|
||||
totalVol: number;
|
||||
emitted: number; received: number;
|
||||
cities: Set<string>;
|
||||
}
|
||||
const clusters: Cluster[] = [];
|
||||
const cityClusterIdx = new Map<string, number>();
|
||||
|
||||
for (const city of cityList) {
|
||||
let bestIdx = -1;
|
||||
|
||||
if (clustered) {
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
const cl = clusters[i];
|
||||
const dx = city.x - cl.cx;
|
||||
const dy = city.y - cl.cy;
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
if (d < CLUSTER_RADIUS && d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx === -1) {
|
||||
clusters.push({
|
||||
cx: city.x, cy: city.y,
|
||||
lat: city.lat, lng: city.lng,
|
||||
totalVol: city.vol,
|
||||
emitted: city.emitted, received: city.received,
|
||||
cities: new Set([city.name]),
|
||||
});
|
||||
cityClusterIdx.set(city.name, clusters.length - 1);
|
||||
} else {
|
||||
const cl = clusters[bestIdx];
|
||||
const newVol = cl.totalVol + city.vol;
|
||||
cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol;
|
||||
cl.cy = (cl.cy * cl.totalVol + city.y * city.vol) / newVol;
|
||||
cl.totalVol = newVol;
|
||||
cl.emitted += city.emitted;
|
||||
cl.received += city.received;
|
||||
cl.cities.add(city.name);
|
||||
cityClusterIdx.set(city.name, bestIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Agréger les corridors en arcs inter-clusters ---
|
||||
interface ClusterArc {
|
||||
fromIdx: number; toIdx: number;
|
||||
totalVolume: number; count: number;
|
||||
categories: { category: TxCategory; count: number; volume: number }[];
|
||||
comments: string[];
|
||||
_catItems: { category: TxCategory; amount: number }[];
|
||||
_comments: string[];
|
||||
}
|
||||
const clArcMap = new Map<string, ClusterArc>();
|
||||
for (const c of corridors) {
|
||||
const fi = cityClusterIdx.get(c.fromCity);
|
||||
const ti = cityClusterIdx.get(c.toCity);
|
||||
if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré
|
||||
const key = `${fi}||${ti}`;
|
||||
if (!clArcMap.has(key)) clArcMap.set(key, { fromIdx: fi, toIdx: ti, totalVolume: 0, count: 0, categories: [], comments: [], _catItems: [], _comments: [] });
|
||||
const ca = clArcMap.get(key)!;
|
||||
ca.totalVolume += c.totalVolume;
|
||||
ca.count += c.count;
|
||||
ca._catItems.push(...c.categories.map((cat) => ({ category: cat.category, amount: cat.volume })));
|
||||
ca._comments.push(...c.comments);
|
||||
}
|
||||
const clusterArcs = [...clArcMap.values()].map((ca) => ({
|
||||
...ca,
|
||||
categories: aggregateCategories(ca._catItems),
|
||||
comments: [...new Set(ca._comments)].filter(Boolean).slice(0, 4),
|
||||
})).sort((a, b) => b.totalVolume - a.totalVolume);
|
||||
|
||||
// --- 4. Couleur de balance par cluster ---
|
||||
const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1);
|
||||
const clusterColors = clusters.map(cl => {
|
||||
const net = cl.received - cl.emitted;
|
||||
const t = net / maxAbsNet;
|
||||
if (Math.abs(t) < NEUTRAL_THRESHOLD) return COLOR_NEUTRAL;
|
||||
return t < 0
|
||||
? lerpColor(COLOR_NEUTRAL, COLOR_NEG, -t)
|
||||
: lerpColor(COLOR_NEUTRAL, COLOR_POS, t);
|
||||
});
|
||||
|
||||
// Cluster de la ville focus
|
||||
const focusClusterIdx = focusCity !== null ? (cityClusterIdx.get(focusCity) ?? -1) : -1;
|
||||
|
||||
// --- 5. Éléments SVG des arcs ---
|
||||
const maxVol = Math.max(...clusterArcs.map(a => a.totalVolume), 1);
|
||||
const arcElems = clusterArcs.map((ca, idx) => {
|
||||
const p1 = { x: clusters[ca.fromIdx].cx, y: clusters[ca.fromIdx].cy };
|
||||
const p2 = { x: clusters[ca.toIdx].cx, y: clusters[ca.toIdx].cy };
|
||||
|
||||
// Point de contrôle bezier quadratique : décalage perpendiculaire au milieu
|
||||
const mx = (p1.x + p2.x) / 2;
|
||||
const my = (p1.y + p2.y) / 2;
|
||||
const dx = p2.x - p1.x;
|
||||
@@ -98,13 +223,12 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
const cx = mx - dy * CURVE;
|
||||
const cy = my + dx * CURVE;
|
||||
|
||||
// Flèche de direction au milieu (t = 0.5) du bezier
|
||||
const t = 0.5;
|
||||
const ax = (1-t)*(1-t)*p1.x + 2*(1-t)*t*cx + t*t*p2.x;
|
||||
const ay = (1-t)*(1-t)*p1.y + 2*(1-t)*t*cy + t*t*p2.y;
|
||||
const tln = Math.sqrt(dx*dx + dy*dy) || 1;
|
||||
const nx = dx / tln; const ny = dy / tln; // tangente normalisée
|
||||
const px = -ny; const py = nx; // perpendiculaire
|
||||
const nx = dx / tln; const ny = dy / tln;
|
||||
const px = -ny; const py = nx;
|
||||
const AR = 5;
|
||||
const arrowPts = [
|
||||
`${ax + nx*AR},${ay + ny*AR}`,
|
||||
@@ -112,40 +236,63 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
`${ax - nx*AR*0.6 - px*AR*0.5},${ay - ny*AR*0.6 - py*AR*0.5}`,
|
||||
].join(' ');
|
||||
|
||||
const ratio = c.totalVolume / maxVol;
|
||||
const strokeW = Math.max(1, 1.5 + Math.log1p(c.totalVolume) * 0.8);
|
||||
const opacity = 0.35 + 0.55 * ratio;
|
||||
const ratio = ca.totalVolume / maxVol;
|
||||
const strokeW = Math.max(1, 1.5 + Math.log1p(ca.totalVolume) * 0.8);
|
||||
const opacity = 0.35 + 0.55 * ratio;
|
||||
|
||||
// Couleur selon focusCity
|
||||
const isFocusFrom = focusCity && c.fromCity === focusCity;
|
||||
const isFocusTo = focusCity && c.toCity === focusCity;
|
||||
const stroke = !focusCity ? `url(#grad${idx})`
|
||||
: isFocusFrom ? '#ff8f00'
|
||||
: isFocusTo ? '#00acc1'
|
||||
: '#2e2f3a';
|
||||
const arrowFill = !focusCity ? '#e65100'
|
||||
: isFocusFrom ? '#ff8f00'
|
||||
: isFocusTo ? '#00acc1'
|
||||
: '#2e2f3a';
|
||||
const isFocusFrom = focusClusterIdx !== -1 && ca.fromIdx === focusClusterIdx;
|
||||
const isFocusTo = focusClusterIdx !== -1 && ca.toIdx === focusClusterIdx;
|
||||
const stroke = focusClusterIdx === -1 ? `url(#grad${idx})`
|
||||
: isFocusFrom ? '#ff8f00'
|
||||
: isFocusTo ? '#00acc1'
|
||||
: '#2e2f3a';
|
||||
const arrowFill = focusClusterIdx === -1 ? '#e65100'
|
||||
: isFocusFrom ? '#ff8f00'
|
||||
: isFocusTo ? '#00acc1'
|
||||
: '#2e2f3a';
|
||||
|
||||
const arcKey = `${ca.fromIdx}||${ca.toIdx}`;
|
||||
const midX = (1-0.5)*(1-0.5)*p1.x + 2*(1-0.5)*0.5*cx + 0.5*0.5*p2.x;
|
||||
const midY = (1-0.5)*(1-0.5)*p1.y + 2*(1-0.5)*0.5*cy + 0.5*0.5*p2.y;
|
||||
return {
|
||||
idx, c, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
|
||||
idx, ca, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
|
||||
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`,
|
||||
arcKey, midX, midY,
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Nœuds ----
|
||||
const nodeElems = [...cityNodes.entries()].map(([name, data]) => {
|
||||
const p = proj(data.lat, data.lng);
|
||||
const vol = data.emitted + data.received;
|
||||
const r = Math.max(3, Math.min(14, 3 + 9 * (vol / maxNodeVol)));
|
||||
return { name, p, r, isSelected: focusCity === name };
|
||||
// --- 6. Éléments SVG des nœuds de clusters ---
|
||||
const maxClVol = Math.max(...clusters.map(cl => cl.totalVol), 1);
|
||||
const nodeElems = clusters.map((cl, idx) => {
|
||||
const r = Math.max(4, Math.min(18, 4 + 11 * (cl.totalVol / maxClVol)));
|
||||
const fillColor = clusterColors[idx];
|
||||
const isSelected = focusClusterIdx === idx;
|
||||
const cityCount = cl.cities.size;
|
||||
// Nom affiché : ville principale (la première dans l'itération = la plus volumineuse)
|
||||
const label = cityCount > 1 ? `+${cityCount}` : [...cl.cities][0];
|
||||
return { idx, cl, r, fillColor, isSelected, cityCount, label };
|
||||
});
|
||||
|
||||
return { arcElems, nodeElems };
|
||||
// tick en dep pour re-projeter sur pan/zoom
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [corridors, cityNodes, focusCity, tick, mapReady]);
|
||||
}, [corridors, cityNodes, focusCity, tick, mapReady, clustered]);
|
||||
|
||||
const [popupIdx, setPopupIdx] = useState<number | null>(null);
|
||||
const [hoveredArc, setHoveredArc] = useState<{ key: string; x: number; y: number } | null>(null);
|
||||
|
||||
// Ferme le popup et le tooltip sur déplacement/zoom
|
||||
useEffect(() => { setPopupIdx(null); setHoveredArc(null); }, [tick]);
|
||||
|
||||
// Handler de clic : ouvre/ferme le popup + focus
|
||||
const handleNodeClick = (nodeIdx: number) => {
|
||||
if (!svgElements) return;
|
||||
const node = svgElements.nodeElems[nodeIdx];
|
||||
const firstCity = [...node.cl.cities][0];
|
||||
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
|
||||
onCityClick(isCurrentFocus ? null : firstCity);
|
||||
setPopupIdx(popupIdx === nodeIdx ? null : nodeIdx);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
|
||||
@@ -173,37 +320,158 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
</defs>
|
||||
|
||||
{/* Arcs bezier */}
|
||||
{svgElements.arcElems.map(a => (
|
||||
<g key={`${a.c.fromCity}-${a.c.toCity}`} opacity={a.opacity}>
|
||||
<path
|
||||
d={a.path}
|
||||
fill="none"
|
||||
stroke={a.stroke}
|
||||
strokeWidth={a.strokeW}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
||||
</g>
|
||||
))}
|
||||
<g style={{ pointerEvents: 'all' }}>
|
||||
{svgElements.arcElems.map(a => (
|
||||
<g
|
||||
key={`${a.ca.fromIdx}-${a.ca.toIdx}`}
|
||||
opacity={hoveredArc && hoveredArc.key !== a.arcKey ? a.opacity * 0.4 : a.opacity}
|
||||
onMouseEnter={() => setHoveredArc({ key: a.arcKey, x: a.midX, y: a.midY })}
|
||||
onMouseLeave={() => setHoveredArc(null)}
|
||||
style={{ cursor: 'default' }}
|
||||
>
|
||||
{/* Zone de hit invisible plus large */}
|
||||
<path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(12, a.strokeW + 8)} />
|
||||
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" />
|
||||
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Nœuds de villes (pointer-events activés uniquement ici) */}
|
||||
{/* Nœuds de clusters (pointer-events activés uniquement ici) */}
|
||||
<g style={{ pointerEvents: 'all' }}>
|
||||
{svgElements.nodeElems.map(node => (
|
||||
<circle
|
||||
key={node.name}
|
||||
cx={node.p.x}
|
||||
cy={node.p.y}
|
||||
r={node.r}
|
||||
fill={node.isSelected ? '#ffffff' : '#d4a843'}
|
||||
stroke={node.isSelected ? '#d4a843' : '#0a0b0f'}
|
||||
strokeWidth={1.5}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onCityClick(focusCity === node.name ? null : node.name)}
|
||||
/>
|
||||
<g key={node.idx} onClick={() => handleNodeClick(node.idx)} style={{ cursor: 'pointer' }}>
|
||||
<circle
|
||||
cx={node.cl.cx}
|
||||
cy={node.cl.cy}
|
||||
r={node.r}
|
||||
fill={node.isSelected ? '#ffffff' : node.fillColor}
|
||||
stroke={node.isSelected ? node.fillColor : '#0a0b0f'}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{node.cityCount > 1 && (
|
||||
<text
|
||||
x={node.cl.cx}
|
||||
y={node.cl.cy + 3.5}
|
||||
textAnchor="middle"
|
||||
fontSize={node.r > 9 ? 9 : 7}
|
||||
fontWeight="bold"
|
||||
fill={node.isSelected ? node.fillColor : '#0a0b0f'}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Tooltip arc — nature des échanges */}
|
||||
{hoveredArc && svgElements && (() => {
|
||||
const arcElem = svgElements.arcElems.find((a) => a.arcKey === hoveredArc.key);
|
||||
if (!arcElem) return null;
|
||||
const { ca } = arcElem;
|
||||
const topCats = ca.categories.filter((c) => c.category !== 'migration' && c.category !== 'ticket').slice(0, 4);
|
||||
const total = ca.categories.reduce((s, c) => s + c.count, 0);
|
||||
const containerW = containerRef.current?.clientWidth ?? 600;
|
||||
const containerH = containerRef.current?.clientHeight ?? 400;
|
||||
const tipW = 200;
|
||||
const tipH = 120;
|
||||
const left = Math.min(hoveredArc.x + 12, containerW - tipW - 8);
|
||||
const top = Math.min(Math.max(8, hoveredArc.y - tipH / 2), containerH - tipH - 8);
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[601] bg-[#0a0b0f]/97 border border-[#2e2f3a] rounded-xl p-3 shadow-2xl pointer-events-none"
|
||||
style={{ left, top, width: tipW }}
|
||||
>
|
||||
<p className="text-[#4b5563] text-[10px] uppercase tracking-widest mb-2">
|
||||
{ca.count} échanges · {ca.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
|
||||
</p>
|
||||
{topCats.length > 0 ? (
|
||||
<div className="space-y-1.5 mb-2">
|
||||
{topCats.map((c) => {
|
||||
const pct = Math.round((c.count / total) * 100);
|
||||
return (
|
||||
<div key={c.category}>
|
||||
<div className="flex justify-between mb-0.5">
|
||||
<span className="text-[#9ca3af] text-[10px]">{CATEGORY_LABELS[c.category]}</span>
|
||||
<span className="text-[#4b5563] text-[10px] font-mono">{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#1e1f2a] rounded-full h-0.5">
|
||||
<div className="h-0.5 rounded-full" style={{ width: `${pct}%`, backgroundColor: CATEGORY_COLORS[c.category] }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{ca.comments.length > 0 && (
|
||||
<div className="border-t border-[#1e1f2a] pt-1.5 space-y-0.5">
|
||||
{ca.comments.slice(0, 3).map((c, i) => (
|
||||
<p key={i} className="text-[#4b5563] text-[10px] truncate italic">"{c}"</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Bouton cluster / villes */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet.heat';
|
||||
import type { Transaction } from '../data/mockData';
|
||||
import type { MemberCity } from '../services/DataService';
|
||||
|
||||
// Leaflet default marker fix (Vite asset pipeline)
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
@@ -10,6 +11,7 @@ L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl });
|
||||
|
||||
interface HeatMapProps {
|
||||
transactions: Transaction[];
|
||||
memberCities?: MemberCity[];
|
||||
}
|
||||
|
||||
const HEAT_OPTIONS: L.HeatMapOptions = {
|
||||
@@ -29,10 +31,11 @@ const HEAT_OPTIONS: L.HeatMapOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export function HeatMap({ transactions }: HeatMapProps) {
|
||||
export function HeatMap({ transactions, memberCities = [] }: HeatMapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const heatRef = useRef<L.HeatLayer | null>(null);
|
||||
const memberLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
// Two img overlays that cross-fade between each other.
|
||||
// The canvas opacity is NEVER touched — it stays at leaflet's default.
|
||||
const prevRef = useRef<HTMLImageElement | null>(null);
|
||||
@@ -59,9 +62,11 @@ export function HeatMap({ transactions }: HeatMapProps) {
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||
|
||||
const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map);
|
||||
const memberLayer = L.layerGroup().addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
heatRef.current = heat;
|
||||
memberLayerRef.current = memberLayer;
|
||||
|
||||
// Pendant zoom/pan : cache les overlays → le canvas live est visible directement.
|
||||
// Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné.
|
||||
@@ -100,9 +105,33 @@ export function HeatMap({ transactions }: HeatMapProps) {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
heatRef.current = null;
|
||||
memberLayerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Overlay membres DU : cercles proportionnels au nombre de membres par ville
|
||||
useEffect(() => {
|
||||
const layer = memberLayerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
if (memberCities.length === 0) return;
|
||||
|
||||
const maxCount = Math.max(...memberCities.map((c) => c.count), 1);
|
||||
for (const city of memberCities) {
|
||||
const radius = 4 + Math.sqrt(city.count / maxCount) * 18;
|
||||
L.circleMarker([city.lat, city.lng], {
|
||||
radius,
|
||||
color: '#00c853',
|
||||
fillColor: '#00c853',
|
||||
fillOpacity: 0.18,
|
||||
weight: 1.5,
|
||||
opacity: 0.7,
|
||||
})
|
||||
.bindTooltip(`<b>${city.city}</b><br/>${city.count} membre${city.count > 1 ? 's' : ''}`, { sticky: true })
|
||||
.addTo(layer);
|
||||
}
|
||||
}, [memberCities]);
|
||||
|
||||
// Crossfade: two img overlays swap roles each frame.
|
||||
// Canvas is never hidden — we only read its pixel data via toDataURL().
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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="Nature des échanges">
|
||||
<Feature icon="🏷" name="Catégorisation automatique">
|
||||
Le commentaire de chaque transaction est analysé et classé en catégories :
|
||||
don & gratitude, alimentation, soin & bien-être, vêtements, culture & loisirs,
|
||||
événement, service & travaux, remboursement.
|
||||
</Feature>
|
||||
<Feature icon="▬" name="Distribution dans le panneau">
|
||||
La section <em>Nature des échanges</em> en bas du panneau latéral affiche
|
||||
la répartition des catégories sous forme de barres proportionnelles
|
||||
sur les transactions commentées de la période courante.
|
||||
</Feature>
|
||||
<Feature icon="⟿" name="Tooltip sur les arcs (vue Flux)">
|
||||
Survoler un arc affiche la distribution des catégories et un échantillon
|
||||
de commentaires bruts pour ce corridor.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Overlay Dividende Universel">
|
||||
<Feature icon="DU" name="Membres actifs géolocalisés">
|
||||
Le bouton <Kbd>DU</Kbd> (à gauche de la carte) affiche en overlay les membres Ğ1
|
||||
actifs (WoT) ayant un profil Cesium+ géolocalisé.
|
||||
Chaque point représente une ville avec des membres actifs.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Source de données">
|
||||
<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;
|
||||
viewMode: 'heatmap' | 'flow';
|
||||
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
||||
geoPercent?: number | null;
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
@@ -17,7 +18,7 @@ const PERIODS = [
|
||||
|
||||
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 [inputVal, setInputVal] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -41,13 +42,13 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
||||
const isCustomActive = !PRESET_DAYS.has(value);
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
|
||||
<div className="flex flex-wrap gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center max-w-[calc(100vw-2rem)]">
|
||||
{PERIODS.map(({ label, days }) => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => { onChange(days); setCustomOpen(false); }}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${value === days && !customOpen
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
@@ -84,7 +85,7 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
||||
<button
|
||||
onClick={openCustom}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${isCustomActive
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
@@ -124,6 +125,12 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
||||
>
|
||||
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
|
||||
</button>
|
||||
{geoPercent != null && (
|
||||
<span className="text-[10px] font-mono text-white px-1 shrink-0">
|
||||
{geoPercent}% Tx géoloc.
|
||||
</span>
|
||||
)}
|
||||
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+115
-12
@@ -1,17 +1,27 @@
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { PeriodStats } from '../services/DataService';
|
||||
import type { FlowStats } from '../data/arcData';
|
||||
import { Sparkline } from './Sparkline';
|
||||
import { ServiceStatusDots } from './ServiceStatusDots';
|
||||
import { useServiceStatus } from '../hooks/useServiceStatus';
|
||||
import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser';
|
||||
import type { Transaction } from '../data/mockData';
|
||||
|
||||
interface StatsPanelProps {
|
||||
stats: PeriodStats | null;
|
||||
loading: boolean;
|
||||
periodDays: number;
|
||||
source: 'live' | 'mock';
|
||||
className?: string;
|
||||
currentUD: number;
|
||||
animationLabel?: string;
|
||||
viewMode?: 'heatmap' | 'flow';
|
||||
flowStats?: FlowStats | null;
|
||||
focusCity?: string | null;
|
||||
onClose?: () => void;
|
||||
onEndpointChange?: () => void;
|
||||
allTimestamps?: number[];
|
||||
transactions?: Transaction[];
|
||||
}
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||
@@ -58,7 +68,9 @@ function CityRow({ city, volume, count, countryCode, accent }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity }: StatsPanelProps) {
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
|
||||
const { subsquid, cesium, recheck } = useServiceStatus();
|
||||
const [openCategory, setOpenCategory] = useState<TxCategory | null>(null);
|
||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||
const prevStats = useRef<PeriodStats | null>(null);
|
||||
|
||||
@@ -82,7 +94,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
if (stats && !loading) prevStats.current = stats;
|
||||
|
||||
return (
|
||||
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
|
||||
<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 */}
|
||||
<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)]">
|
||||
@@ -93,8 +105,21 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
Ğ1Flux
|
||||
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
|
||||
</h1>
|
||||
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
|
||||
<ServiceStatusDots
|
||||
subsquid={subsquid}
|
||||
cesium={cesium}
|
||||
onEndpointChange={() => { recheck(); onEndpointChange?.(); }}
|
||||
/>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto text-[#4b5563] hover:text-white transition-colors p-1 text-lg leading-none"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
@@ -102,13 +127,18 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
|
||||
</p>
|
||||
|
||||
{/* Period label */}
|
||||
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
|
||||
{animationLabel
|
||||
? <><span className="text-[#d4a843]">▶</span> <span className="text-[#d4a843]">{animationLabel}</span></>
|
||||
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
|
||||
}
|
||||
</p>
|
||||
{/* Period label + sparkline */}
|
||||
<div className="border-t border-[#1e1f2a] pt-3 space-y-2">
|
||||
<p className="text-[#4b5563] text-xs">
|
||||
{animationLabel
|
||||
? <><span className="text-[#d4a843]">▶</span> <span className="text-[#d4a843]">{animationLabel}</span></>
|
||||
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
|
||||
}
|
||||
</p>
|
||||
{!animationLabel && allTimestamps.length > 0 && (
|
||||
<Sparkline timestamps={allTimestamps} periodDays={periodDays} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Vue HEATMAP ---- */}
|
||||
{viewMode === 'heatmap' && (
|
||||
@@ -247,7 +277,13 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
{/* Balance nette */}
|
||||
{flowStats.netBalance.length > 0 && (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
|
||||
<p className="text-[10px] text-[#4b5563] flex items-center gap-1.5">
|
||||
<span style={{ color: '#ff6d00' }}>●</span>émetteur
|
||||
<span style={{ color: '#00c853' }}>●</span>récepteur
|
||||
</p>
|
||||
</div>
|
||||
{flowStats.netBalance.map((c) => (
|
||||
<div key={c.city} className="flex items-center justify-between">
|
||||
<span className="text-white text-xs truncate">{c.city}</span>
|
||||
@@ -277,6 +313,73 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Nature des échanges */}
|
||||
{!loading && stats && stats.categoryBreakdown.length > 0 && stats.commentedCount > 0 && (
|
||||
<div className="space-y-2 border-t border-[#1e1f2a] pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nature des échanges</p>
|
||||
<p className="text-[#2e2f3a] text-[10px] font-mono">{stats.commentedCount} commentés</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{stats.categoryBreakdown
|
||||
.filter((c) => c.category !== 'migration' && c.category !== 'ticket')
|
||||
.slice(0, 7)
|
||||
.map((c) => {
|
||||
const pct = Math.round((c.count / stats.commentedCount) * 100);
|
||||
const isOpen = openCategory === c.category;
|
||||
const detail = transactions.filter((t) => t.category === c.category && t.comment);
|
||||
return (
|
||||
<div key={c.category}>
|
||||
<button
|
||||
onClick={() => setOpenCategory(isOpen ? null : c.category)}
|
||||
className="w-full text-left group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className={`text-[11px] transition-colors ${isOpen ? 'text-white' : 'text-[#9ca3af] group-hover:text-white'}`}>
|
||||
{isOpen ? '▾' : '▸'} {CATEGORY_LABELS[c.category]}
|
||||
</span>
|
||||
<span className="text-[#4b5563] text-[10px] font-mono">{c.count} · {pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#1e1f2a] rounded-full h-1">
|
||||
<div
|
||||
className="h-1 rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: CATEGORY_COLORS[c.category] }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-1.5 mb-1 bg-[#0a0b0f] border border-[#1e1f2a] rounded-lg overflow-hidden">
|
||||
{detail.length === 0 ? (
|
||||
<p className="text-[#4b5563] text-[10px] px-3 py-2">Aucun commentaire disponible.</p>
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto divide-y divide-[#1e1f2a]">
|
||||
{detail.slice(0, 30).map((t) => (
|
||||
<div key={t.id} className="px-3 py-1.5 flex items-start justify-between gap-2">
|
||||
<p className="text-[#9ca3af] text-[10px] italic leading-snug flex-1 min-w-0 truncate">
|
||||
"{t.comment}"
|
||||
</p>
|
||||
<span className="text-[#d4a843] text-[10px] font-mono shrink-0">
|
||||
{t.amount.toLocaleString('fr-FR', { maximumFractionDigits: 1 })} Ğ1
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{detail.length > 30 && (
|
||||
<p className="text-[#4b5563] text-[10px] px-3 py-1.5 text-center">
|
||||
+{detail.length - 30} autres
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
|
||||
<p className="text-[#2e2f3a] text-xs text-center">
|
||||
|
||||
+25
-8
@@ -1,4 +1,6 @@
|
||||
import type { Transaction } from './mockData';
|
||||
import type { TxCategory } from './commentParser';
|
||||
import { aggregateCategories } from './commentParser';
|
||||
|
||||
export interface TransactionArc {
|
||||
id: string;
|
||||
@@ -14,6 +16,8 @@ export interface TransactionArc {
|
||||
toCity: string;
|
||||
toCountry: string;
|
||||
toKey: string;
|
||||
comment: string | null;
|
||||
category: TxCategory;
|
||||
}
|
||||
|
||||
/** Corridor agrégé par paire de villes (fromCity → toCity). */
|
||||
@@ -28,6 +32,8 @@ export interface Corridor {
|
||||
toCountry: string;
|
||||
totalVolume: number;
|
||||
count: number;
|
||||
categories: { category: TxCategory; count: number; volume: number }[];
|
||||
comments: string[]; // échantillon de commentaires bruts (max 5, non nuls)
|
||||
}
|
||||
|
||||
export interface FlowStats {
|
||||
@@ -40,21 +46,30 @@ export interface FlowStats {
|
||||
|
||||
/** Agrège les arcs individuels en corridors ville→ville, triés par volume. */
|
||||
export function buildCorridors(arcs: TransactionArc[]): Corridor[] {
|
||||
const map = new Map<string, Corridor>();
|
||||
const map = new Map<string, { corridor: Omit<Corridor, 'categories' | 'comments'>; items: TransactionArc[] }>();
|
||||
for (const arc of arcs) {
|
||||
const key = `${arc.fromCity}||${arc.toCity}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
|
||||
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
|
||||
totalVolume: 0, count: 0,
|
||||
corridor: {
|
||||
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
|
||||
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
|
||||
totalVolume: 0, count: 0,
|
||||
},
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
const c = map.get(key)!;
|
||||
c.totalVolume += arc.amount;
|
||||
c.count++;
|
||||
const entry = map.get(key)!;
|
||||
entry.corridor.totalVolume += arc.amount;
|
||||
entry.corridor.count++;
|
||||
entry.items.push(arc);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalVolume - a.totalVolume);
|
||||
|
||||
return [...map.values()].map(({ corridor, items }) => ({
|
||||
...corridor,
|
||||
categories: aggregateCategories(items.map((a) => ({ category: a.category, amount: a.amount }))),
|
||||
comments: items.map((a) => a.comment).filter((c): c is string => !!c).slice(0, 5),
|
||||
})).sort((a, b) => b.totalVolume - a.totalVolume);
|
||||
}
|
||||
|
||||
export function computeFlowStats(arcs: TransactionArc[]): FlowStats {
|
||||
@@ -114,6 +129,8 @@ export function buildMockArcs(transactions: Transaction[]): TransactionArc[] {
|
||||
toLat: to.lat, toLng: to.lng,
|
||||
toCity: to.city, toCountry: to.countryCode,
|
||||
toKey: to.toKey,
|
||||
comment: from.comment,
|
||||
category: from.category,
|
||||
});
|
||||
}
|
||||
return arcs;
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
export type TxCategory =
|
||||
| 'migration'
|
||||
| 'ticket'
|
||||
| 'remboursement'
|
||||
| 'don'
|
||||
| 'alimentation'
|
||||
| 'soin'
|
||||
| 'vetements'
|
||||
| 'culture'
|
||||
| 'evenement'
|
||||
| 'service'
|
||||
| 'autre';
|
||||
|
||||
export const CATEGORY_LABELS: Record<TxCategory, string> = {
|
||||
migration: 'Migration',
|
||||
ticket: 'Ticket',
|
||||
remboursement:'Remboursement',
|
||||
don: 'Don / Gratitude',
|
||||
alimentation: 'Alimentation',
|
||||
soin: 'Soin & bien-être',
|
||||
vetements: 'Vêtements',
|
||||
culture: 'Culture & loisirs',
|
||||
evenement: 'Événement',
|
||||
service: 'Service & travaux',
|
||||
autre: 'Autre',
|
||||
};
|
||||
|
||||
export const CATEGORY_COLORS: Record<TxCategory, string> = {
|
||||
migration: '#4b5563',
|
||||
ticket: '#6b7280',
|
||||
remboursement:'#f59e0b',
|
||||
don: '#ec4899',
|
||||
alimentation: '#22c55e',
|
||||
soin: '#06b6d4',
|
||||
vetements: '#a78bfa',
|
||||
culture: '#f97316',
|
||||
evenement: '#eab308',
|
||||
service: '#3b82f6',
|
||||
autre: '#374151',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Règles de détection — ordre = priorité, première règle qui matche gagne
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Rule {
|
||||
category: TxCategory;
|
||||
patterns: RegExp[];
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
category: 'migration',
|
||||
patterns: [
|
||||
/ğecko:csmigration/i,
|
||||
/csmigration/i,
|
||||
/migration\s*v[12]/i,
|
||||
/\bğecko\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'ticket',
|
||||
patterns: [
|
||||
/\bticket\s+\d{6,}/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'remboursement',
|
||||
patterns: [
|
||||
/\bretour\b/i,
|
||||
/\brendu\b/i,
|
||||
/\bremboursement\b/i,
|
||||
/\bdevolución\b/i,
|
||||
/\bdevolucio\b/i,
|
||||
/\brimborso\b/i,
|
||||
/\brégul\b/i,
|
||||
/\bregulariz/i,
|
||||
/double\s*paiement/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'don',
|
||||
patterns: [
|
||||
/\bdon\b/i,
|
||||
/\bdonación\b/i,
|
||||
/\bdonazione\b/i,
|
||||
/\bdonacio\b/i,
|
||||
/\bcadeau\b/i,
|
||||
/\bgratitud/i,
|
||||
/\bgratitude\b/i,
|
||||
/\bmerci\b/i,
|
||||
/\bgracias\b/i,
|
||||
/\bgràcies\b/i,
|
||||
/\bgracies\b/i,
|
||||
/\bobrigad/i,
|
||||
/\bthank/i,
|
||||
/\bgràcia/i,
|
||||
/\bgrazie\b/i,
|
||||
/\bgrazie\b/i,
|
||||
/\bbienvenu/i,
|
||||
/\bwelcome\b/i,
|
||||
/\bchukurei\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'alimentation',
|
||||
patterns: [
|
||||
/\brepas\b/i,
|
||||
/\bpaella\b/i,
|
||||
/\bcrêpe\b/i,
|
||||
/\bcrepe\b/i,
|
||||
/\bfalafel\b/i,
|
||||
/\bpain\b/i,
|
||||
/\bpan\b/i,
|
||||
/\bgâteau/i,
|
||||
/\bgateau\b/i,
|
||||
/\bgalleta/i,
|
||||
/\bpastis\b/i,
|
||||
/\bpastel\b/i,
|
||||
/\bburger\b/i,
|
||||
/\bkombucha\b/i,
|
||||
/\bœuf/i,
|
||||
/\boeufs?\b/i,
|
||||
/\bhuevo/i,
|
||||
/\bfromage\b/i,
|
||||
/\bflan\b/i,
|
||||
/\balgue/i,
|
||||
/\blegum/i,
|
||||
/\bfruits?\b/i,
|
||||
/\bpomme/i,
|
||||
/\blimonad/i,
|
||||
/\blimonada\b/i,
|
||||
/\blégumin/i,
|
||||
/\bporro\b/i,
|
||||
/\bcarbassa\b/i,
|
||||
/\bsobrasada\b/i,
|
||||
/\biarmelada\b/i,
|
||||
/\bnispero/i,
|
||||
/\bbizcocho\b/i,
|
||||
/\bchocolat/i,
|
||||
/\balmendra/i,
|
||||
/\bincienso/i,
|
||||
/\bincens/i,
|
||||
/alimentation/i,
|
||||
/\bépice/i,
|
||||
/\bcava\b/i,
|
||||
/\bvin\b/i,
|
||||
/\baceit/i,
|
||||
/huile\s*d.?olive/i,
|
||||
/\bgerminado/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'soin',
|
||||
patterns: [
|
||||
/\bsoin\b/i,
|
||||
/\bmassage\b/i,
|
||||
/\bbaume\b/i,
|
||||
/\bhuile\s*essenti/i,
|
||||
/\btisane\b/i,
|
||||
/\bterapia\b/i,
|
||||
/\bthérapie\b/i,
|
||||
/\bherboristerie\b/i,
|
||||
/\bplante/i,
|
||||
/\bhomeopat/i,
|
||||
/\baromath/i,
|
||||
/\breiki\b/i,
|
||||
/\bacupunct/i,
|
||||
/\bostéo/i,
|
||||
/\bkinesio/i,
|
||||
/\btirage\b/i,
|
||||
/\bcart(e|as)\b/i,
|
||||
/\bmandalas?\b/i,
|
||||
/\bconsoude\b/i,
|
||||
/\bsauge\b/i,
|
||||
/\bromarin\b/i,
|
||||
/\bserum\b/i,
|
||||
/\bsérum\b/i,
|
||||
/\bpeeling\b/i,
|
||||
/\bbifasico\b/i,
|
||||
/\bormus\b/i,
|
||||
/eau\s*de\s*mer/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'vetements',
|
||||
patterns: [
|
||||
/\bjupe\b/i,
|
||||
/\bpantalon\b/i,
|
||||
/\bblouson\b/i,
|
||||
/\bchaussure/i,
|
||||
/\bvêtement/i,
|
||||
/\bropa\b/i,
|
||||
/\bcardigan\b/i,
|
||||
/\bmanteau\b/i,
|
||||
/\bchemise\b/i,
|
||||
/\btricot\b/i,
|
||||
/\blaine\b/i,
|
||||
/\btissus?\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'culture',
|
||||
patterns: [
|
||||
/\blivre\b/i,
|
||||
/\blivres\b/i,
|
||||
/\blibro\b/i,
|
||||
/\bmusique\b/i,
|
||||
/\bmusica\b/i,
|
||||
/\bmúsica\b/i,
|
||||
/\bconcierto\b/i,
|
||||
/\bconcert\b/i,
|
||||
/\bcd\b/i,
|
||||
/\bvídeo\b/i,
|
||||
/\bvideo\b/i,
|
||||
/\bpelícula\b/i,
|
||||
/\bfilm\b/i,
|
||||
/\bpoème\b/i,
|
||||
/\bpoema\b/i,
|
||||
/\bbd\b/i,
|
||||
/\bdessin\b/i,
|
||||
/\bnexus\b/i,
|
||||
/\bnaruto\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'evenement',
|
||||
patterns: [
|
||||
/\bg1ntada\b/i,
|
||||
/\bğ1ntada\b/i,
|
||||
/\bmercat\b/i,
|
||||
/\bmarché\b/i,
|
||||
/\bjornadas?\b/i,
|
||||
/\bfestival\b/i,
|
||||
/\bfête\b/i,
|
||||
/\bfiesta\b/i,
|
||||
/\brassemblement\b/i,
|
||||
/\bcampillo\b/i,
|
||||
/\beutopia\b/i,
|
||||
/\brencontre\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'service',
|
||||
patterns: [
|
||||
/\bservice\b/i,
|
||||
/\batelier\b/i,
|
||||
/\baccompagnement\b/i,
|
||||
/\btravaux\b/i,
|
||||
/\bbarnum\b/i,
|
||||
/\baccueil\b/i,
|
||||
/\bhébergement\b/i,
|
||||
/\blogement\b/i,
|
||||
/\bnuit\b/i,
|
||||
/\bnuits\b/i,
|
||||
/\bnit\b/i,
|
||||
/\bnits\b/i,
|
||||
/\bvisita\b/i,
|
||||
/\bamortigua/i,
|
||||
/\bamortisseur/i,
|
||||
/\bréparation\b/i,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function parseComment(remark: string | null): TxCategory {
|
||||
if (!remark) return 'autre';
|
||||
const text = remark.trim();
|
||||
if (!text) return 'autre';
|
||||
|
||||
for (const rule of RULES) {
|
||||
if (rule.patterns.some((p) => p.test(text))) {
|
||||
return rule.category;
|
||||
}
|
||||
}
|
||||
return 'autre';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agrégation sur un tableau de catégories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CategoryCount {
|
||||
category: TxCategory;
|
||||
count: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export function aggregateCategories(
|
||||
items: { category: TxCategory; amount: number }[]
|
||||
): CategoryCount[] {
|
||||
const map = new Map<TxCategory, CategoryCount>();
|
||||
for (const { category, amount } of items) {
|
||||
if (!map.has(category)) map.set(category, { category, count: 0, volume: 0 });
|
||||
const entry = map.get(category)!;
|
||||
entry.count++;
|
||||
entry.volume += amount;
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.count - a.count);
|
||||
}
|
||||
+20
-1
@@ -1,3 +1,5 @@
|
||||
import type { TxCategory } from './commentParser';
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
timestamp: number; // Unix ms (entier)
|
||||
@@ -8,6 +10,8 @@ export interface Transaction {
|
||||
countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
|
||||
fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars
|
||||
toKey: string;
|
||||
comment: string | null;
|
||||
category: TxCategory;
|
||||
}
|
||||
|
||||
// French + European cities where Ğ1 is used
|
||||
@@ -79,6 +83,8 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
||||
countryCode: 'FR',
|
||||
fromKey: generateKey(),
|
||||
toKey: generateKey(),
|
||||
comment: null,
|
||||
category: 'autre',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,7 +120,20 @@ export function computeStats(transactions: Transaction[]) {
|
||||
.slice(0, 3)
|
||||
.map(([name, data]) => ({ name, ...data }));
|
||||
|
||||
return { totalVolume, transactionCount, topCities };
|
||||
const catMap = new Map<import('./commentParser').TxCategory, { count: number; volume: number }>();
|
||||
let commentedCount = 0;
|
||||
for (const tx of transactions) {
|
||||
if (tx.comment) commentedCount++;
|
||||
const entry = catMap.get(tx.category) ?? { count: 0, volume: 0 };
|
||||
entry.count++;
|
||||
entry.volume += tx.amount;
|
||||
catMap.set(tx.category, entry);
|
||||
}
|
||||
const categoryBreakdown = [...catMap.entries()]
|
||||
.map(([category, v]) => ({ category, ...v }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { totalVolume, transactionCount, topCities, categoryBreakdown, commentedCount };
|
||||
}
|
||||
|
||||
export type { };
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getSubsquidUrl, getCesiumUrl } from '../services/EndpointConfig';
|
||||
|
||||
export type ServiceState = 'checking' | 'ok' | 'slow' | 'error';
|
||||
|
||||
export interface ServiceStatus {
|
||||
state: ServiceState;
|
||||
latencyMs: number | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ServicesStatus {
|
||||
subsquid: ServiceStatus;
|
||||
cesium: ServiceStatus;
|
||||
recheck: () => void;
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 8_000;
|
||||
const SLOW_THRESHOLD_MS = 2_000;
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
async function pingSubsquid(url: string): Promise<number> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: '{ __typename }' }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await res.json();
|
||||
return Date.now() - start;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function pingCesium(url: string): Promise<number> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${url}/user/profile/_search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ size: 0, query: { match_all: {} } }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return Date.now() - start;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function latencyToState(ms: number): ServiceState {
|
||||
if (ms < SLOW_THRESHOLD_MS) return 'ok';
|
||||
return 'slow';
|
||||
}
|
||||
|
||||
const CHECKING: ServiceStatus = { state: 'checking', latencyMs: null, url: '' };
|
||||
|
||||
export function useServiceStatus(): ServicesStatus {
|
||||
const [subsquid, setSubsquid] = useState<ServiceStatus>(CHECKING);
|
||||
const [cesium, setCesium] = useState<ServiceStatus>(CHECKING);
|
||||
const [version, setVersion] = useState(0);
|
||||
const cancelled = useRef(false);
|
||||
|
||||
const runChecks = useCallback(async () => {
|
||||
const squidUrl = getSubsquidUrl();
|
||||
const cesiumUrl = getCesiumUrl();
|
||||
|
||||
setSubsquid({ state: 'checking', latencyMs: null, url: squidUrl });
|
||||
setCesium( { state: 'checking', latencyMs: null, url: cesiumUrl });
|
||||
|
||||
const [squidResult, cesiumResult] = await Promise.allSettled([
|
||||
pingSubsquid(squidUrl),
|
||||
pingCesium(cesiumUrl),
|
||||
]);
|
||||
|
||||
if (cancelled.current) return;
|
||||
|
||||
if (squidResult.status === 'fulfilled') {
|
||||
const ms = squidResult.value;
|
||||
setSubsquid({ state: latencyToState(ms), latencyMs: ms, url: squidUrl });
|
||||
} else {
|
||||
setSubsquid({ state: 'error', latencyMs: null, url: squidUrl });
|
||||
}
|
||||
|
||||
if (cesiumResult.status === 'fulfilled') {
|
||||
const ms = cesiumResult.value;
|
||||
setCesium({ state: latencyToState(ms), latencyMs: ms, url: cesiumUrl });
|
||||
} else {
|
||||
setCesium({ state: 'error', latencyMs: null, url: cesiumUrl });
|
||||
}
|
||||
}, [version]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
cancelled.current = false;
|
||||
runChecks();
|
||||
const interval = setInterval(runChecks, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled.current = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [runChecks]);
|
||||
|
||||
const recheck = useCallback(() => setVersion((v) => v + 1), []);
|
||||
|
||||
return { subsquid, cesium, recheck };
|
||||
}
|
||||
|
||||
export async function testEndpoint(type: 'subsquid' | 'cesium', url: string): Promise<number> {
|
||||
return type === 'subsquid' ? pingSubsquid(url) : pingCesium(url);
|
||||
}
|
||||
@@ -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,9 @@
|
||||
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
|
||||
*/
|
||||
|
||||
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
|
||||
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter';
|
||||
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter';
|
||||
import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
|
||||
import { parseComment } from '../data/commentParser';
|
||||
import {
|
||||
getTransactionsForPeriod,
|
||||
computeStats,
|
||||
@@ -69,9 +70,14 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
}
|
||||
|
||||
// Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+)
|
||||
// Pour les membres WoT : via keyMap (genesis key = _id Cesium+)
|
||||
// Pour les non-membres : conversion directe SS58 → Duniter key
|
||||
const resolveKey = (ss58: string): string =>
|
||||
keyMap.get(ss58) ?? ss58ToDuniterKey(ss58);
|
||||
|
||||
const allDuniterKeys = [...new Set([
|
||||
...rawTransfers.map((t) => keyMap.get(t.fromId)),
|
||||
...rawTransfers.map((t) => keyMap.get(t.toId)),
|
||||
...rawTransfers.map((t) => t.fromId ? resolveKey(t.fromId) : undefined),
|
||||
...rawTransfers.map((t) => t.toId ? resolveKey(t.toId) : undefined),
|
||||
].filter(Boolean) as string[])];
|
||||
|
||||
// Résolution géo par clé Duniter (_id Cesium+)
|
||||
@@ -89,7 +95,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
const arcs: TransactionArc[] = [];
|
||||
|
||||
for (const t of rawTransfers) {
|
||||
const fromDuniterKey = keyMap.get(t.fromId);
|
||||
const fromDuniterKey = t.fromId ? resolveKey(t.fromId) : undefined;
|
||||
if (!fromDuniterKey) continue;
|
||||
const fromGeo = geoMap.get(fromDuniterKey);
|
||||
if (!fromGeo) continue;
|
||||
@@ -107,10 +113,12 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
countryCode: fromGeo.countryCode,
|
||||
fromKey: t.fromId,
|
||||
toKey: t.toId,
|
||||
comment: t.comment,
|
||||
category: parseComment(t.comment),
|
||||
});
|
||||
|
||||
// Arc : les deux extrémités géolocalisées + villes différentes
|
||||
const toDuniterKey = keyMap.get(t.toId);
|
||||
const toDuniterKey = t.toId ? resolveKey(t.toId) : undefined;
|
||||
if (!toDuniterKey) continue;
|
||||
const toGeo = geoMap.get(toDuniterKey);
|
||||
if (!toGeo) continue;
|
||||
@@ -128,12 +136,59 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
toLat: toGeo.lat, toLng: toGeo.lng,
|
||||
toCity, toCountry: toGeo.countryCode,
|
||||
toKey: t.toId,
|
||||
comment: t.comment,
|
||||
category: parseComment(t.comment),
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -142,6 +197,8 @@ export interface PeriodStats {
|
||||
transactionCount: number; // total blockchain (y compris non-géolocalisés)
|
||||
geoCount: number; // transactions visibles sur la carte
|
||||
topCities: { name: string; volume: number; count: number; countryCode: string }[];
|
||||
categoryBreakdown: { category: import('../data/commentParser').TxCategory; count: number; volume: number }[];
|
||||
commentedCount: number; // nb de transactions avec un commentaire
|
||||
}
|
||||
|
||||
export interface DataResult {
|
||||
@@ -183,6 +240,8 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
transactionCount: totalCount,
|
||||
geoCount: geolocated.length,
|
||||
topCities: base.topCities,
|
||||
categoryBreakdown: base.categoryBreakdown,
|
||||
commentedCount: base.commentedCount,
|
||||
},
|
||||
source: 'live',
|
||||
currentUD,
|
||||
|
||||
@@ -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 { getCesiumUrl } from '../EndpointConfig';
|
||||
|
||||
export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro';
|
||||
|
||||
@@ -136,7 +137,7 @@ export async function resolveGeoByKeys(
|
||||
_source: ['title', 'city', 'geoPoint'],
|
||||
};
|
||||
|
||||
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, {
|
||||
const response = await fetch(`${getCesiumUrl()}/user/profile/_search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(query),
|
||||
@@ -163,6 +164,23 @@ export async function resolveGeoByKeys(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Même que resolveGeoByKeys mais traite les grands tableaux par lots.
|
||||
* Nécessaire pour les 6000+ membres actifs (évite des requêtes ES trop grandes).
|
||||
*/
|
||||
export async function resolveGeoByKeysBatched(
|
||||
duniterKeys: string[],
|
||||
batchSize = 500,
|
||||
): Promise<Map<string, GeoProfile>> {
|
||||
const result = new Map<string, GeoProfile>();
|
||||
for (let i = 0; i < duniterKeys.length; i += batchSize) {
|
||||
const batch = duniterKeys.slice(i, i + batchSize);
|
||||
const partial = await resolveGeoByKeys(batch);
|
||||
for (const [k, v] of partial) result.set(k, v);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les coordonnées de plusieurs membres Ğ1 par leur nom d'identité.
|
||||
* Envoie une requête Elasticsearch multi-terms en un seul appel.
|
||||
@@ -193,7 +211,7 @@ export async function resolveGeoByNames(
|
||||
_source: ['title', 'city', 'geoPoint'],
|
||||
};
|
||||
|
||||
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, {
|
||||
const response = await fetch(`${getCesiumUrl()}/user/profile/_search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(query),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { getSubsquidUrl } from '../EndpointConfig';
|
||||
|
||||
export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
|
||||
|
||||
@@ -28,6 +29,7 @@ const SubsquidTransferNodeSchema = z.object({
|
||||
from: z.object({
|
||||
linkedIdentity: z.object({ name: z.string() }).nullable(),
|
||||
}).nullable(),
|
||||
comment: z.object({ remark: z.string() }).nullable().optional(),
|
||||
});
|
||||
|
||||
const SubsquidResponseSchema = z.object({
|
||||
@@ -51,6 +53,7 @@ export interface RawTransfer {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide)
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -76,6 +79,9 @@ const TRANSFERS_QUERY = `
|
||||
name
|
||||
}
|
||||
}
|
||||
comment {
|
||||
remark
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +142,7 @@ const IDENTITY_KEY_MAP_QUERY = `
|
||||
* car previousId = clé génesis = clé Ed25519 v1 = _id dans Cesium+
|
||||
*/
|
||||
export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
|
||||
const response = await fetch(SUBSQUID_ENDPOINT, {
|
||||
const response = await fetch(getSubsquidUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: IDENTITY_KEY_MAP_QUERY }),
|
||||
@@ -157,7 +163,7 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
|
||||
export async function fetchCurrentUD(): Promise<number> {
|
||||
const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue
|
||||
try {
|
||||
const response = await fetch(SUBSQUID_ENDPOINT, {
|
||||
const response = await fetch(getSubsquidUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -174,6 +180,41 @@ export async function fetchCurrentUD(): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Membres actifs WoT (isMember = true)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTIVE_MEMBERS_QUERY = `
|
||||
query {
|
||||
identities(filter: { isMember: { equalTo: true } }, first: 20000) {
|
||||
nodes {
|
||||
accountId
|
||||
ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) {
|
||||
nodes { previousId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** Retourne la liste des clés SS58 de tous les membres WoT actifs. */
|
||||
export async function fetchActiveMemberKeys(): Promise<string[]> {
|
||||
const res = await fetch(getSubsquidUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: ACTIVE_MEMBERS_QUERY }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Subsquid HTTP ${res.status}`);
|
||||
const raw = await res.json();
|
||||
if (raw.errors?.length) throw new Error(raw.errors[0].message);
|
||||
|
||||
return (raw.data.identities.nodes as { accountId: string; ownerKeyChange: { nodes: { previousId: string }[] } }[])
|
||||
.map((node) => {
|
||||
const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId;
|
||||
return ss58ToDuniterKey(genesisKey);
|
||||
});
|
||||
}
|
||||
|
||||
export interface FetchTransfersResult {
|
||||
transfers: RawTransfer[];
|
||||
totalCount: number;
|
||||
@@ -187,7 +228,7 @@ export async function fetchTransfers(
|
||||
Date.now() - periodDays * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
const response = await fetch(SUBSQUID_ENDPOINT, {
|
||||
const response = await fetch(getSubsquidUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -217,6 +258,7 @@ export async function fetchTransfers(
|
||||
fromId: node.fromId ?? '',
|
||||
toId: node.toId ?? '',
|
||||
fromName: node.from?.linkedIdentity?.name ?? '',
|
||||
comment: node.comment?.remark ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user