Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3148d74331 | |||
| dfe832728e | |||
| 782b063b25 | |||
| 88e2232cfb | |||
| 6b42a75140 | |||
| 8e396cd331 | |||
| 6b7591db32 | |||
| 0d9415ae6a | |||
| 7c9d626b98 | |||
| 0136ff9ce1 | |||
| 575ca7a1fc | |||
| 8f9a11c4e8 | |||
| 63f50d5762 | |||
| 6fc1705f6d | |||
| 15807c7bcb | |||
| bac113e51b | |||
| 6d01c8d29e | |||
| 46b11710cc | |||
| 78ede01d11 |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "g1flux",
|
||||
"private": true,
|
||||
"version": "1.3.2",
|
||||
"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*
|
||||
+106
-17
@@ -4,8 +4,9 @@ import { PeriodSelector } from './components/PeriodSelector';
|
||||
import { HeatMap } from './components/HeatMap';
|
||||
import { FlowMap } from './components/FlowMap';
|
||||
import { AnimationPlayer } from './components/AnimationPlayer';
|
||||
import { fetchData } from './services/DataService';
|
||||
import type { PeriodStats } from './services/DataService';
|
||||
import { SearchBar } from './components/SearchBar';
|
||||
import { fetchData, fetchMemberCities } from './services/DataService';
|
||||
import type { PeriodStats, MemberCity } from './services/DataService';
|
||||
import type { Transaction } from './data/mockData';
|
||||
import type { TransactionArc } from './data/arcData';
|
||||
import { computeStats } from './data/mockData';
|
||||
@@ -13,9 +14,11 @@ 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';
|
||||
import { type Period, periodKey, isPastRange } from './types/period';
|
||||
|
||||
export default function App() {
|
||||
const [periodDays, setPeriodDays] = useState(7);
|
||||
const [period, setPeriod] = useState<Period>(initialUrlState.period);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [arcs, setArcs] = useState<TransactionArc[]>([]);
|
||||
const [stats, setStats] = useState<PeriodStats | null>(null);
|
||||
@@ -25,17 +28,39 @@ 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 animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||
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 handlePeriodChange = (days: number) => {
|
||||
const animation = useAnimation(transactions, arcs, period, allTimestamps);
|
||||
|
||||
// Synchronise l'état dans l'URL (deep link / partage)
|
||||
useUrlSync(period, viewMode, focusCity);
|
||||
|
||||
const handlePeriodChange = (newPeriod: Period) => {
|
||||
animation.deactivate();
|
||||
setPeriodDays(days);
|
||||
setPeriod(newPeriod);
|
||||
};
|
||||
|
||||
const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
|
||||
@@ -43,13 +68,38 @@ 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;
|
||||
|
||||
const load = (showLoading: boolean) => {
|
||||
if (showLoading) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
fetchData(periodDays)
|
||||
fetchData(period)
|
||||
.then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => {
|
||||
if (!cancelled) {
|
||||
setTransactions(transactions);
|
||||
@@ -68,10 +118,11 @@ export default function App() {
|
||||
};
|
||||
|
||||
load(true);
|
||||
const interval = setInterval(() => load(false), 30_000);
|
||||
const interval = isPastRange(period) ? null : setInterval(() => load(false), 120_000);
|
||||
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [periodDays]);
|
||||
return () => { cancelled = true; if (interval) clearInterval(interval); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [periodKey(period), endpointVersion]);
|
||||
|
||||
// Stats heatmap sur la fenêtre courante en mode animation
|
||||
const visibleStats: PeriodStats | null = animation.active
|
||||
@@ -94,13 +145,16 @@ export default function App() {
|
||||
const statsPanelProps = {
|
||||
stats: visibleStats,
|
||||
loading,
|
||||
periodDays,
|
||||
period,
|
||||
source,
|
||||
currentUD,
|
||||
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
|
||||
viewMode,
|
||||
flowStats,
|
||||
focusCity,
|
||||
allTimestamps,
|
||||
onEndpointChange: () => setEndpointVersion((v) => v + 1),
|
||||
transactions,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -111,7 +165,10 @@ export default function App() {
|
||||
{/* Map area */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{viewMode === 'heatmap' ? (
|
||||
<HeatMap transactions={animation.visibleTransactions} />
|
||||
<HeatMap
|
||||
transactions={animation.visibleTransactions}
|
||||
memberCities={showMembers ? memberCities : []}
|
||||
/>
|
||||
) : (
|
||||
<FlowMap
|
||||
arcs={animation.active ? animation.visibleArcs : arcs}
|
||||
@@ -131,10 +188,44 @@ export default function App() {
|
||||
</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 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
||||
<PeriodSelector
|
||||
value={periodDays}
|
||||
period={period}
|
||||
onChange={handlePeriodChange}
|
||||
animationActive={animation.active}
|
||||
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||
@@ -143,7 +234,6 @@ export default function App() {
|
||||
geoPercent={visibleStats && visibleStats.transactionCount > 0
|
||||
? Math.round((visibleStats.geoCount / visibleStats.transactionCount) * 100)
|
||||
: null}
|
||||
onInfo={() => setInfoOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -184,7 +274,6 @@ export default function App() {
|
||||
onPlay={animation.play}
|
||||
onPause={animation.pause}
|
||||
onSpeedChange={animation.setSpeed}
|
||||
onClose={animation.deactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ interface AnimationPlayerProps {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSpeedChange: (s: 1 | 2 | 4) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AnimationPlayer({
|
||||
@@ -21,7 +20,6 @@ export function AnimationPlayer({
|
||||
onPlay,
|
||||
onPause,
|
||||
onSpeedChange,
|
||||
onClose,
|
||||
}: AnimationPlayerProps) {
|
||||
const frame = frames[currentIndex];
|
||||
|
||||
@@ -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,224 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
KNOWN_SUBSQUID_NODES,
|
||||
KNOWN_CESIUM_NODES,
|
||||
getSubsquidUrl,
|
||||
getCesiumUrl,
|
||||
setSubsquidUrl,
|
||||
setCesiumUrl,
|
||||
} from '../services/EndpointConfig';
|
||||
import { discoverSquidNodes, clearPeerCache } from '../services/PeerDiscovery';
|
||||
import { testEndpoint } from '../hooks/useServiceStatus';
|
||||
|
||||
interface Props {
|
||||
service: 'subsquid' | 'cesium';
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
url: string;
|
||||
state: 'testing' | 'ok' | 'slow' | 'error';
|
||||
latencyMs: number | null;
|
||||
}
|
||||
|
||||
const LABELS = { subsquid: 'SubSquid', cesium: 'Cesium+' };
|
||||
|
||||
export function EndpointPopover({ service, onClose, onSaved }: Props) {
|
||||
const currentUrl = service === 'subsquid' ? getSubsquidUrl() : getCesiumUrl();
|
||||
const knownNodes = service === 'subsquid' ? KNOWN_SUBSQUID_NODES : KNOWN_CESIUM_NODES;
|
||||
|
||||
const [inputUrl, setInputUrl] = useState(currentUrl);
|
||||
const [testResults, setTestResults] = useState<Map<string, TestResult>>(new Map());
|
||||
const [discoveredUrls, setDiscoveredUrls] = useState<string[]>([]);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discoverVersion, setDiscoverVersion] = useState(0);
|
||||
|
||||
const testUrl = async (url: string) => {
|
||||
setTestResults((prev) => new Map(prev).set(url, { url, state: 'testing', latencyMs: null }));
|
||||
try {
|
||||
const ms = await testEndpoint(service, url);
|
||||
setTestResults((prev) =>
|
||||
new Map(prev).set(url, { url, state: ms < 2000 ? 'ok' : 'slow', latencyMs: ms })
|
||||
);
|
||||
} catch {
|
||||
setTestResults((prev) => new Map(prev).set(url, { url, state: 'error', latencyMs: null }));
|
||||
}
|
||||
};
|
||||
|
||||
// Découverte des nœuds réseau (SubSquid uniquement)
|
||||
useEffect(() => {
|
||||
if (service !== 'subsquid') return;
|
||||
setDiscovering(true);
|
||||
setDiscoveredUrls([]);
|
||||
discoverSquidNodes().then((urls) => {
|
||||
setDiscoveredUrls(urls);
|
||||
setDiscovering(false);
|
||||
urls.forEach((url) => testUrl(url));
|
||||
});
|
||||
}, [discoverVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const refreshDiscovery = () => {
|
||||
clearPeerCache();
|
||||
setDiscoverVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = inputUrl.trim();
|
||||
if (!trimmed) return;
|
||||
if (service === 'subsquid') setSubsquidUrl(trimmed);
|
||||
else setCesiumUrl(trimmed);
|
||||
onSaved();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const dot = (state: TestResult['state']) => {
|
||||
if (state === 'testing') return <span className="text-[#6b7280] animate-pulse">●</span>;
|
||||
if (state === 'ok') return <span className="text-emerald-400">●</span>;
|
||||
if (state === 'slow') return <span className="text-amber-400">●</span>;
|
||||
return <span className="text-red-500">●</span>;
|
||||
};
|
||||
|
||||
const NodeRow = ({ url, label }: { url: string; label?: string }) => {
|
||||
const result = testResults.get(url);
|
||||
const isActive = inputUrl === url;
|
||||
const hostname = label ?? (() => { try { return new URL(url).hostname; } catch { return url; } })();
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
|
||||
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
|
||||
}`}
|
||||
onClick={() => setInputUrl(url)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{hostname}</p>
|
||||
<p className="text-[#4b5563] text-xs font-mono truncate">{url}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3 shrink-0">
|
||||
{result && (
|
||||
<span className="text-xs font-mono text-[#6b7280]">
|
||||
{dot(result.state)}
|
||||
{result.latencyMs !== null && ` ${result.latencyMs} ms`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); testUrl(url); }}
|
||||
className="text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
|
||||
>
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 space-y-5 max-h-[85vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-white font-bold text-base">
|
||||
Configurer {LABELS[service]}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-[#4b5563] hover:text-white transition-colors text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Nœuds connus (statiques) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nœuds connus</p>
|
||||
{knownNodes.map((node) => (
|
||||
<NodeRow key={node.url} url={node.url} label={node.label} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Nœuds découverts via duniter_peerings (SubSquid uniquement) */}
|
||||
{service === 'subsquid' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Réseau Ğ1</p>
|
||||
<button
|
||||
onClick={refreshDiscovery}
|
||||
disabled={discovering}
|
||||
className="text-xs text-[#4b5563] hover:text-[#d4a843] disabled:opacity-40 transition-colors"
|
||||
title="Actualiser la liste des nœuds"
|
||||
>
|
||||
{discovering ? <span className="animate-spin inline-block">↻</span> : '↻ Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
{discovering && discoveredUrls.length === 0 && (
|
||||
<p className="text-xs text-[#4b5563] pl-1">Découverte en cours…</p>
|
||||
)}
|
||||
{!discovering && discoveredUrls.length === 0 && (
|
||||
<p className="text-xs text-[#4b5563] pl-1">Aucun nœud trouvé</p>
|
||||
)}
|
||||
{discoveredUrls.map((url) => (
|
||||
<NodeRow key={url} url={url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL personnalisée */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">URL personnalisée</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder={service === 'subsquid' ? 'https://…/v1/graphql' : 'https://…'}
|
||||
className="flex-1 bg-[#0a0b0f] border border-[#2e2f3a] rounded-xl px-3 py-2 text-white text-sm font-mono placeholder-[#2e2f3a] focus:outline-none focus:border-[#d4a843]/60 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => testUrl(inputUrl.trim())}
|
||||
disabled={!inputUrl.trim()}
|
||||
className="text-xs text-[#4b5563] hover:text-[#d4a843] disabled:opacity-30 transition-colors px-3 py-2 border border-[#2e2f3a] rounded-xl"
|
||||
>
|
||||
Tester
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const result = testResults.get(inputUrl.trim());
|
||||
const isKnown = [...knownNodes, ...discoveredUrls.map((u) => ({ url: u }))].some(
|
||||
(n) => n.url === inputUrl.trim()
|
||||
);
|
||||
if (!result || isKnown) return null;
|
||||
return (
|
||||
<p className="text-xs font-mono text-[#6b7280] pl-1">
|
||||
{dot(result.state)}
|
||||
{result.state === 'testing' && ' Test en cours…'}
|
||||
{result.state === 'ok' && ` OK · ${result.latencyMs} ms`}
|
||||
{result.state === 'slow' && ` Lent · ${result.latencyMs} ms`}
|
||||
{result.state === 'error' && ' Inaccessible'}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-[#4b5563] hover:text-white border border-[#2e2f3a] rounded-xl transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!inputUrl.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-[#d4a843] text-[#0a0b0f] rounded-xl hover:bg-[#e0b84d] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
Utiliser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
+88
-17
@@ -17,13 +17,15 @@ function lerpColor(hex1: string, hex2: string, t: number): string {
|
||||
}
|
||||
|
||||
const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
|
||||
const COLOR_NEG = '#ff6d00'; // orange vif
|
||||
const COLOR_NEG = '#e53935'; // rouge vif
|
||||
const COLOR_POS = '#00c853'; // vert vif
|
||||
const 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';
|
||||
@@ -169,6 +171,10 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
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) {
|
||||
@@ -176,12 +182,18 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
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 });
|
||||
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()].sort((a, b) => b.totalVolume - a.totalVolume);
|
||||
const clusterArcs = [...clArcMap.values()].map((ca) => ({
|
||||
...ca,
|
||||
categories: aggregateCategories(ca._catItems),
|
||||
comments: [...new Set(ca._comments)].filter(Boolean).slice(0, 4),
|
||||
})).sort((a, b) => b.totalVolume - a.totalVolume);
|
||||
|
||||
// --- 4. Couleur de balance par cluster ---
|
||||
const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1);
|
||||
@@ -239,9 +251,13 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
: isFocusTo ? '#00acc1'
|
||||
: '#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, 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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -263,9 +279,10 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
}, [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 sur déplacement/zoom
|
||||
useEffect(() => { setPopupIdx(null); }, [tick]);
|
||||
// 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) => {
|
||||
@@ -303,18 +320,22 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
</defs>
|
||||
|
||||
{/* Arcs bezier */}
|
||||
{svgElements.arcElems.map(a => (
|
||||
<g key={`${a.ca.fromIdx}-${a.ca.toIdx}`} 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(2, a.strokeW)} pointerEvents="stroke" />
|
||||
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" pointerEvents="stroke" />
|
||||
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Nœuds de clusters (pointer-events activés uniquement ici) */}
|
||||
<g style={{ pointerEvents: 'all' }}>
|
||||
@@ -347,6 +368,56 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
</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)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -54,8 +54,8 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
|
||||
</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é ·{' '}
|
||||
<span className="text-orange-400">Orange</span> = émetteur net.
|
||||
<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,
|
||||
@@ -83,11 +83,42 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Raccourcis clavier">
|
||||
<Feature icon="⌨" name="Navigation animation">
|
||||
<Kbd>←</Kbd> <Kbd>→</Kbd> frame précédente / suivante ·
|
||||
<Kbd>Espace</Kbd> lecture / pause.
|
||||
</Feature>
|
||||
<Feature icon="⌨" name="Vues & panneaux">
|
||||
<Kbd>H</Kbd> basculer Heatmap ↔ Flux ·
|
||||
<Kbd>Échap</Kbd> quitter l'animation ou fermer ce panneau.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Recherche">
|
||||
<Feature icon="⌕" name="Identité ou clé Ğ1">
|
||||
Le bouton <Kbd>⌕</Kbd> (à gauche de la carte) accepte un nom d'identité Ğ1
|
||||
(ex : "Alice") ou une clé publique <Kbd>g1…</Kbd>.
|
||||
Il bascule automatiquement en vue Flux et met la ville en focus.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="URL partageable">
|
||||
<Feature icon="🔗" name="Deep link">
|
||||
L'URL reflète l'état courant : période, vue, ville sélectionnée.
|
||||
Partager l'URL restitue exactement la même configuration.
|
||||
Exemple : <span className="font-mono text-[#d4a843] text-xs">?period=30&view=flow&city=Paris</span>
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Statistiques">
|
||||
<Feature icon="📊" name="Panneau latéral">
|
||||
Volume total en Ğ1, nombre de transactions, top émetteurs et receveurs,
|
||||
répartition géographique. Se met à jour en temps réel et pendant l'animation.
|
||||
</Feature>
|
||||
<Feature icon="▂▅█" name="Sparkline">
|
||||
Mini-graphique d'activité journalière affiché sous la période,
|
||||
calculé depuis les timestamps déjà en mémoire.
|
||||
</Feature>
|
||||
<Feature icon="☰" name="Mobile">
|
||||
Le panneau est accessible via le bouton <Kbd>☰</Kbd> en haut à gauche.
|
||||
</Feature>
|
||||
@@ -96,9 +127,42 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
|
||||
</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, actualisées toutes les 30 secondes.
|
||||
Données temps réel de la blockchain Ğ1v2 via SubSquid, actualisées toutes les 30 secondes.
|
||||
Les profils de géolocalisation sont fournis par Cesium+.
|
||||
</Feature>
|
||||
<Feature icon="●" name="Statut des services">
|
||||
Deux indicateurs en bas du panneau latéral affichent l'état de SubSquid et Cesium+ en temps réel
|
||||
(<span className="text-emerald-400">vert</span> OK ·{' '}
|
||||
<span className="text-amber-400">jaune</span> lent ·{' '}
|
||||
<span className="text-red-400">rouge</span> inaccessible).
|
||||
Un clic sur un indicateur permet de configurer ou changer l'endpoint.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,60 +1,103 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { type Period } from '../types/period';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
value: number;
|
||||
onChange: (days: number) => void;
|
||||
period: Period;
|
||||
onChange: (period: Period) => void;
|
||||
animationActive: boolean;
|
||||
onAnimate: () => void;
|
||||
viewMode: 'heatmap' | 'flow';
|
||||
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
||||
geoPercent?: number | null;
|
||||
onInfo: () => void;
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
const PRESETS = [
|
||||
{ label: '24h', days: 1 },
|
||||
{ label: '7 jours', days: 7 },
|
||||
{ label: '30 jours', days: 30 },
|
||||
];
|
||||
|
||||
const PRESET_DAYS = new Set([1, 7, 30]);
|
||||
|
||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent, onInfo }: PeriodSelectorProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [inputVal, setInputVal] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
function toDateInputValue(d: Date): string {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
export function PeriodSelector({ period, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [rangeOpen, setRangeOpen] = useState(false);
|
||||
const [inputVal, setInputVal] = useState('');
|
||||
const [fromInput, setFromInput] = useState('');
|
||||
const [toInput, setToInput] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fromRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isSliding = period.type === 'sliding';
|
||||
const isPreset = isSliding && PRESET_DAYS.has(period.days);
|
||||
const isCustomDay = isSliding && !PRESET_DAYS.has(period.days);
|
||||
const isRange = period.type === 'range';
|
||||
|
||||
const todayStr = toDateInputValue(new Date());
|
||||
|
||||
// Ouvre le champ custom avec la valeur courante pré-remplie
|
||||
const openCustom = () => {
|
||||
setInputVal(PRESET_DAYS.has(value) ? '' : String(value));
|
||||
setRangeOpen(false);
|
||||
setInputVal(isCustomDay ? String(period.days) : '');
|
||||
setCustomOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) inputRef.current?.focus();
|
||||
}, [customOpen]);
|
||||
const openRange = () => {
|
||||
setCustomOpen(false);
|
||||
if (isRange) {
|
||||
setFromInput(toDateInputValue(period.from));
|
||||
setToInput(toDateInputValue(period.to));
|
||||
} else {
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - 30 * 86_400_000);
|
||||
setFromInput(toDateInputValue(from));
|
||||
setToInput(toDateInputValue(to));
|
||||
}
|
||||
setRangeOpen(true);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
useEffect(() => { if (customOpen) inputRef.current?.focus(); }, [customOpen]);
|
||||
useEffect(() => { if (rangeOpen) fromRef.current?.focus(); }, [rangeOpen]);
|
||||
|
||||
const commitDays = () => {
|
||||
const n = parseInt(inputVal, 10);
|
||||
if (n >= 1 && n <= 365) onChange(n);
|
||||
if (n >= 1) onChange({ type: 'sliding', days: n });
|
||||
setCustomOpen(false);
|
||||
};
|
||||
|
||||
const isCustomActive = !PRESET_DAYS.has(value);
|
||||
const commitRange = () => {
|
||||
if (!fromInput || !toInput) return;
|
||||
const from = new Date(fromInput);
|
||||
const to = new Date(toInput);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
if (isNaN(from.getTime()) || isNaN(to.getTime()) || from >= to) return;
|
||||
onChange({ type: 'range', from, to });
|
||||
setRangeOpen(false);
|
||||
};
|
||||
|
||||
const btnClass = (active: boolean) => `
|
||||
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${active
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`;
|
||||
|
||||
const rangeLabel = isRange
|
||||
? `${period.from.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} → ${period.to.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}`
|
||||
: 'Plage';
|
||||
|
||||
return (
|
||||
<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 }) => (
|
||||
|
||||
{/* Préréglages */}
|
||||
{PRESETS.map(({ label, days }) => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => { onChange(days); setCustomOpen(false); }}
|
||||
className={`
|
||||
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]'
|
||||
}
|
||||
`}
|
||||
onClick={() => { onChange({ type: 'sliding', days }); setCustomOpen(false); setRangeOpen(false); }}
|
||||
className={btnClass(isPreset && (period as { days: number }).days === days && !customOpen && !rangeOpen)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -62,53 +105,68 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
||||
|
||||
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||
|
||||
{/* Bouton Personnaliser + champ inline */}
|
||||
{/* Nombre de jours personnalisé */}
|
||||
{customOpen ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
if (e.key === 'Escape') setCustomOpen(false);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitDays(); if (e.key === 'Escape') setCustomOpen(false); }}
|
||||
onBlur={commitDays}
|
||||
placeholder="jours"
|
||||
className="w-16 px-2 py-1 text-sm bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] text-center focus:outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span className="text-[#6b7280] text-xs">j</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={openCustom}
|
||||
className={`
|
||||
px-3 py-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]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCustomActive ? `${value} jours` : 'Personnaliser'}
|
||||
<button onClick={openCustom} className={btnClass(isCustomDay && !rangeOpen)}>
|
||||
{isCustomDay ? `${period.days} jours` : 'N jours'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Plage de dates */}
|
||||
{rangeOpen ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={fromRef}
|
||||
type="date"
|
||||
value={fromInput}
|
||||
max={toInput || todayStr}
|
||||
onChange={(e) => setFromInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setRangeOpen(false); }}
|
||||
className="w-32 px-2 py-1 text-xs bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] focus:outline-none"
|
||||
/>
|
||||
<span className="text-[#6b7280] text-xs">→</span>
|
||||
<input
|
||||
type="date"
|
||||
value={toInput}
|
||||
min={fromInput}
|
||||
max={todayStr}
|
||||
onChange={(e) => setToInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitRange(); if (e.key === 'Escape') setRangeOpen(false); }}
|
||||
className="w-32 px-2 py-1 text-xs bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={commitRange}
|
||||
disabled={!fromInput || !toInput || fromInput >= toInput}
|
||||
className="px-2 py-1 text-xs text-[#0a0b0f] bg-[#d4a843] rounded-md disabled:opacity-30 hover:bg-[#e0b84d] transition-colors"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<button onClick={() => setRangeOpen(false)} className="text-[#4b5563] hover:text-white text-xs">✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={openRange} className={btnClass(isRange && !customOpen)}>
|
||||
{rangeLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||
|
||||
<button
|
||||
onClick={onAnimate}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${animationActive
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<button onClick={onAnimate} className={btnClass(animationActive)}>
|
||||
▶ Animer
|
||||
</button>
|
||||
|
||||
@@ -116,31 +174,16 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
||||
|
||||
<button
|
||||
onClick={() => onViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap')}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${viewMode === 'flow'
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
className={btnClass(viewMode === 'flow')}
|
||||
>
|
||||
{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 className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||
|
||||
<button
|
||||
onClick={onInfo}
|
||||
className="px-2 py-1.5 rounded-md text-sm text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23] transition-all duration-200 cursor-pointer leading-none"
|
||||
aria-label="Aide"
|
||||
>
|
||||
ℹ
|
||||
</button>
|
||||
</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,75 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface SparklineProps {
|
||||
timestamps: number[];
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini bar-chart SVG affichant l'activité sur la période.
|
||||
* Utilise les timestamps déjà en mémoire — aucune requête supplémentaire.
|
||||
*/
|
||||
export function Sparkline({ timestamps, fromMs, toMs }: SparklineProps) {
|
||||
const buckets = useMemo(() => {
|
||||
if (timestamps.length === 0) return [];
|
||||
const duration = toMs - fromMs;
|
||||
const days = duration / 864e5;
|
||||
const n = days <= 1 ? 24 : Math.min(Math.ceil(days), 30);
|
||||
const step = duration / n;
|
||||
const counts = new Array(n).fill(0);
|
||||
for (const ts of timestamps) {
|
||||
const i = Math.floor((ts - fromMs) / step);
|
||||
if (i >= 0 && i < n) counts[i]++;
|
||||
}
|
||||
return counts;
|
||||
}, [timestamps, fromMs, toMs]);
|
||||
|
||||
if (buckets.length === 0) return null;
|
||||
|
||||
const duration = toMs - fromMs;
|
||||
const days = duration / 864e5;
|
||||
|
||||
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;
|
||||
|
||||
const fmtLabel = (ms: number) => {
|
||||
if (days <= 1) return new Date(ms).getHours() + 'h';
|
||||
return new Date(ms).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
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>{fmtLabel(fromMs)}</span>
|
||||
<span>{fmtLabel(toMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+103
-12
@@ -1,11 +1,17 @@
|
||||
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';
|
||||
import { type Period, periodToDates, periodToDays } from '../types/period';
|
||||
|
||||
interface StatsPanelProps {
|
||||
stats: PeriodStats | null;
|
||||
loading: boolean;
|
||||
periodDays: number;
|
||||
period: Period;
|
||||
source: 'live' | 'mock';
|
||||
className?: string;
|
||||
currentUD: number;
|
||||
@@ -14,6 +20,9 @@ interface StatsPanelProps {
|
||||
flowStats?: FlowStats | null;
|
||||
focusCity?: string | null;
|
||||
onClose?: () => void;
|
||||
onEndpointChange?: () => void;
|
||||
allTimestamps?: number[];
|
||||
transactions?: Transaction[];
|
||||
}
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||
@@ -60,8 +69,14 @@ function CityRow({ city, volume, count, countryCode, accent }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className }: StatsPanelProps) {
|
||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||
export function StatsPanel({ stats, loading, period, 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 { from, to } = periodToDates(period);
|
||||
const days = periodToDays(period);
|
||||
const periodLabel = period.type === 'range'
|
||||
? `${from.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })} → ${to.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||||
: days === 1 ? '24 dernières heures' : `${days} derniers jours`;
|
||||
const prevStats = useRef<PeriodStats | null>(null);
|
||||
|
||||
// Calcule le delta d'une valeur par rapport au refresh précédent
|
||||
@@ -95,7 +110,11 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
Ğ1Flux
|
||||
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
|
||||
</h1>
|
||||
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
|
||||
<ServiceStatusDots
|
||||
subsquid={subsquid}
|
||||
cesium={cesium}
|
||||
onEndpointChange={() => { recheck(); onEndpointChange?.(); }}
|
||||
/>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
@@ -113,13 +132,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} fromMs={from.getTime()} toMs={to.getTime()} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Vue HEATMAP ---- */}
|
||||
{viewMode === 'heatmap' && (
|
||||
@@ -294,6 +318,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);
|
||||
}
|
||||
+22
-4
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,12 +94,11 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
||||
const POOL_GENERATED_AT = Date.now();
|
||||
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
export function getTransactionsForPeriod(periodDays: number): Transaction[] {
|
||||
export function getTransactionsForPeriod(from: Date, to: Date): Transaction[] {
|
||||
const drift = Date.now() - POOL_GENERATED_AT;
|
||||
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
|
||||
return TRANSACTION_POOL
|
||||
.map((tx) => ({ ...tx, timestamp: tx.timestamp + drift }))
|
||||
.filter((tx) => tx.timestamp >= cutoff);
|
||||
.filter((tx) => tx.timestamp >= from.getTime() && tx.timestamp <= to.getTime());
|
||||
}
|
||||
|
||||
export function computeStats(transactions: Transaction[]) {
|
||||
@@ -114,7 +119,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 { };
|
||||
|
||||
+48
-37
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { Transaction } from '../data/mockData';
|
||||
import type { TransactionArc } from '../data/arcData';
|
||||
import { type Period, periodToDates, periodKey } from '../types/period';
|
||||
|
||||
export interface TimeFrame {
|
||||
label: string;
|
||||
@@ -8,47 +9,57 @@ export interface TimeFrame {
|
||||
to: number; // Unix ms
|
||||
}
|
||||
|
||||
function buildFrames(periodDays: number): TimeFrame[] {
|
||||
const now = Date.now();
|
||||
const start = now - periodDays * 24 * 60 * 60 * 1000;
|
||||
function buildFrames(fromMs: number, toMs: number): TimeFrame[] {
|
||||
const duration = toMs - fromMs;
|
||||
const days = duration / 86_400_000;
|
||||
|
||||
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
|
||||
new Date(ms).toLocaleDateString('fr-FR', opts);
|
||||
|
||||
if (periodDays === 1) {
|
||||
return Array.from({ length: 24 }, (_, i) => {
|
||||
const from = start + i * 3_600_000;
|
||||
const to = from + 3_600_000;
|
||||
// ≤ 2 jours : frames horaires
|
||||
if (days <= 2) {
|
||||
const frames: TimeFrame[] = [];
|
||||
let cursor = fromMs;
|
||||
while (cursor < toMs) {
|
||||
const from = cursor;
|
||||
const to = Math.min(cursor + 3_600_000, toMs);
|
||||
const h = new Date(from).getHours();
|
||||
return {
|
||||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h – ${h + 1}h`,
|
||||
frames.push({
|
||||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h`,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
});
|
||||
});
|
||||
cursor = to;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
if (periodDays === 7) {
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const from = start + i * 86_400_000;
|
||||
const to = from + 86_400_000;
|
||||
return {
|
||||
// ≤ 14 jours : frames journalières
|
||||
if (days <= 14) {
|
||||
const frames: TimeFrame[] = [];
|
||||
let cursor = fromMs;
|
||||
while (cursor < toMs) {
|
||||
const from = cursor;
|
||||
const to = Math.min(cursor + 86_400_000, toMs);
|
||||
frames.push({
|
||||
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
|
||||
from,
|
||||
to,
|
||||
};
|
||||
});
|
||||
});
|
||||
cursor = to;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
// 30 days → half-week frames (3.5 days ≈ 9–10 frames)
|
||||
const HALF_WEEK = 3.5 * 86_400_000;
|
||||
// > 14 jours : frames hebdomadaires
|
||||
const WEEK = 7 * 86_400_000;
|
||||
const frames: TimeFrame[] = [];
|
||||
let cursor = start;
|
||||
while (cursor < now) {
|
||||
let cursor = fromMs;
|
||||
while (cursor < toMs) {
|
||||
const from = cursor;
|
||||
const to = Math.min(cursor + HALF_WEEK, now);
|
||||
const to = Math.min(cursor + WEEK, toMs);
|
||||
frames.push({
|
||||
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} – ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
|
||||
label: `${fmt(from, { day: 'numeric', month: 'short' })} – ${fmt(to - 1, { day: 'numeric', month: 'short' })}`,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
@@ -57,32 +68,33 @@ function buildFrames(periodDays: number): TimeFrame[] {
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], periodDays: number, allTimestamps: number[] = []) {
|
||||
export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], period: Period, allTimestamps: number[] = []) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<1 | 2 | 4>(2);
|
||||
|
||||
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
|
||||
const key = periodKey(period);
|
||||
|
||||
const frames = useMemo(() => {
|
||||
const { from, to } = periodToDates(period);
|
||||
return buildFrames(from.getTime(), to.getTime());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
// Reset cursor when period or activation changes.
|
||||
// Stop playback only on deactivation — not on activation, so activate() can
|
||||
// start playing immediately without being overridden by this effect.
|
||||
useEffect(() => {
|
||||
setCurrentIndex(0);
|
||||
if (!active) setPlaying(false);
|
||||
}, [periodDays, active]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, active]);
|
||||
|
||||
// Auto-advance: one step every (2000 / speed) ms
|
||||
// Auto-advance: one step every (1500 / speed) ms
|
||||
useEffect(() => {
|
||||
if (!playing || !active) return;
|
||||
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
|
||||
const delay = 1500 / speed;
|
||||
const t = setTimeout(() => {
|
||||
setCurrentIndex((i) => {
|
||||
if (i >= frames.length - 1) {
|
||||
setPlaying(false);
|
||||
return i;
|
||||
}
|
||||
if (i >= frames.length - 1) { setPlaying(false); return i; }
|
||||
return i + 1;
|
||||
});
|
||||
}, delay);
|
||||
@@ -103,7 +115,6 @@ export function useAnimation(transactions: Transaction[], arcs: TransactionArc[]
|
||||
return arcs.filter((a) => a.timestamp >= frame.from && a.timestamp < frame.to);
|
||||
}, [active, arcs, frames, currentIndex]);
|
||||
|
||||
// Nombre total de transfers (géo + non-géo) dans la frame courante
|
||||
const frameTotalCount = useMemo(() => {
|
||||
if (!active || frames.length === 0 || allTimestamps.length === 0) return null;
|
||||
const frame = frames[currentIndex];
|
||||
|
||||
@@ -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,63 @@
|
||||
/**
|
||||
* 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 (mode glissant)
|
||||
* ?from=2026-01-01&to=2026-01-31&view=flow (mode plage)
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { type Period, periodKey } from '../types/period';
|
||||
|
||||
function parseInitialState(): { period: Period; view: 'heatmap' | 'flow'; city: string | null } {
|
||||
const p = new URLSearchParams(window.location.search);
|
||||
const view = p.get('view') === 'flow' ? 'flow' : 'heatmap';
|
||||
const city = p.get('city') ?? null;
|
||||
|
||||
// Mode plage : ?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
const fromStr = p.get('from');
|
||||
const toStr = p.get('to');
|
||||
if (fromStr && toStr) {
|
||||
const from = new Date(fromStr);
|
||||
const to = new Date(toStr);
|
||||
if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from < to) {
|
||||
return { period: { type: 'range', from, to }, view, city };
|
||||
}
|
||||
}
|
||||
|
||||
// Mode glissant : ?period=30
|
||||
const days = parseInt(p.get('period') ?? '', 10);
|
||||
return {
|
||||
period: { type: 'sliding', days: Number.isFinite(days) && days >= 1 ? days : 7 },
|
||||
view,
|
||||
city,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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(
|
||||
period: Period,
|
||||
viewMode: 'heatmap' | 'flow',
|
||||
focusCity: string | null,
|
||||
) {
|
||||
const key = periodKey(period);
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams();
|
||||
if (period.type === 'range') {
|
||||
p.set('from', period.from.toISOString().split('T')[0]);
|
||||
p.set('to', period.to.toISOString().split('T')[0]);
|
||||
} else if (period.days !== 7) {
|
||||
p.set('period', String(period.days));
|
||||
}
|
||||
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);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, viewMode, focusCity]);
|
||||
}
|
||||
+75
-13
@@ -12,8 +12,10 @@
|
||||
* 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 { type Period, periodToDates, periodToDays } from '../types/period';
|
||||
import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
|
||||
import { parseComment } from '../data/commentParser';
|
||||
import {
|
||||
getTransactionsForPeriod,
|
||||
computeStats,
|
||||
@@ -46,16 +48,17 @@ async function getIdentityKeyMap(): Promise<Map<string, string>> {
|
||||
return map;
|
||||
}
|
||||
|
||||
async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
async function fetchLiveTransactions(period: Period): Promise<{
|
||||
geolocated: Transaction[];
|
||||
arcs: TransactionArc[];
|
||||
totalCount: number;
|
||||
totalVolume: number;
|
||||
allTimestamps: number[];
|
||||
}> {
|
||||
// ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
|
||||
const limit = Math.max(2000, Math.ceil(periodDays * 600));
|
||||
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit);
|
||||
const { from, to } = periodToDates(period);
|
||||
const days = periodToDays(period);
|
||||
const limit = Math.max(2000, Math.ceil(days * 600));
|
||||
const { transfers: rawTransfers, totalCount } = await fetchTransfers(from, to, limit);
|
||||
if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
|
||||
|
||||
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
|
||||
@@ -69,9 +72,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 +97,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 +115,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 +138,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 +199,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 {
|
||||
@@ -153,10 +212,11 @@ export interface DataResult {
|
||||
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
|
||||
}
|
||||
|
||||
export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
export async function fetchData(period: Period): Promise<DataResult> {
|
||||
if (!USE_LIVE_API) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const transactions = getTransactionsForPeriod(periodDays);
|
||||
const { from, to } = periodToDates(period);
|
||||
const transactions = getTransactionsForPeriod(from, to);
|
||||
const base = computeStats(transactions);
|
||||
const arcs = buildMockArcs(transactions);
|
||||
return {
|
||||
@@ -170,7 +230,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
}
|
||||
|
||||
const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
|
||||
fetchLiveTransactions(periodDays),
|
||||
fetchLiveTransactions(period),
|
||||
getCurrentUD(),
|
||||
]);
|
||||
const base = computeStats(geolocated);
|
||||
@@ -183,6 +243,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);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
const DUNITER_RPC = 'https://rpc.duniter.org';
|
||||
const CACHE_KEY = 'geoflux-peers-v1';
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface PeerCache {
|
||||
urls: string[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
function normalizeSquidUrl(raw: string): string {
|
||||
const url = raw.replace(/\/$/, '');
|
||||
return url.endsWith('/v1/graphql') ? url : `${url}/v1/graphql`;
|
||||
}
|
||||
|
||||
export async function discoverSquidNodes(): Promise<string[]> {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
const parsed: PeerCache = JSON.parse(cached);
|
||||
if (Date.now() - parsed.fetchedAt < CACHE_TTL_MS) return parsed.urls;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8_000);
|
||||
try {
|
||||
const res = await fetch(DUNITER_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'duniter_peerings', params: [], id: 1 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await res.json();
|
||||
const peers: { peer_id: string; endpoints: { protocol: string; address: string }[] }[] =
|
||||
data?.result?.peerings ?? [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const peer of peers) {
|
||||
for (const ep of peer.endpoints ?? []) {
|
||||
if (ep.protocol === 'squid' && ep.address) {
|
||||
const normalized = normalizeSquidUrl(ep.address);
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
urls.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ urls, fetchedAt: Date.now() }));
|
||||
return urls;
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPeerCache(): void {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
@@ -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,17 +53,18 @@ export interface RawTransfer {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide)
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------------------
|
||||
const TRANSFERS_QUERY = `
|
||||
query GetTransfers($since: Datetime!, $limit: Int!) {
|
||||
query GetTransfers($since: Datetime!, $until: Datetime!, $limit: Int!) {
|
||||
transfers(
|
||||
orderBy: TIMESTAMP_DESC
|
||||
first: $limit
|
||||
filter: { timestamp: { greaterThanOrEqualTo: $since } }
|
||||
filter: { timestamp: { greaterThanOrEqualTo: $since, lessThanOrEqualTo: $until } }
|
||||
) {
|
||||
totalCount
|
||||
nodes {
|
||||
@@ -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,25 +180,60 @@ 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;
|
||||
}
|
||||
|
||||
export async function fetchTransfers(
|
||||
periodDays: number,
|
||||
from: Date,
|
||||
to: Date,
|
||||
limit = 2000
|
||||
): Promise<FetchTransfersResult> {
|
||||
const since = new Date(
|
||||
Date.now() - periodDays * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
const since = from.toISOString();
|
||||
const until = to.toISOString();
|
||||
|
||||
const response = await fetch(SUBSQUID_ENDPOINT, {
|
||||
const response = await fetch(getSubsquidUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: TRANSFERS_QUERY,
|
||||
variables: { since, limit },
|
||||
variables: { since, until, limit },
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -217,6 +258,7 @@ export async function fetchTransfers(
|
||||
fromId: node.fromId ?? '',
|
||||
toId: node.toId ?? '',
|
||||
fromName: node.from?.linkedIdentity?.name ?? '',
|
||||
comment: node.comment?.remark ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type Period =
|
||||
| { type: 'sliding'; days: number }
|
||||
| { type: 'range'; from: Date; to: Date }
|
||||
|
||||
export function periodToDates(period: Period): { from: Date; to: Date } {
|
||||
if (period.type === 'range') return { from: period.from, to: period.to };
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - period.days * 86_400_000);
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
export function periodToDays(period: Period): number {
|
||||
const { from, to } = periodToDates(period);
|
||||
return Math.max(1, Math.ceil((to.getTime() - from.getTime()) / 86_400_000));
|
||||
}
|
||||
|
||||
/** Clé stable pour deps React — ne change pas entre deux renders pour le mode glissant */
|
||||
export function periodKey(period: Period): string {
|
||||
return period.type === 'sliding'
|
||||
? `s-${period.days}`
|
||||
: `r-${period.from.toISOString().split('T')[0]}-${period.to.toISOString().split('T')[0]}`;
|
||||
}
|
||||
|
||||
/** Vrai si la plage est entièrement dans le passé (auto-refresh inutile) */
|
||||
export function isPastRange(period: Period): boolean {
|
||||
if (period.type === 'sliding') return false;
|
||||
return period.to.getTime() < Date.now() - 5 * 60 * 1000;
|
||||
}
|
||||
Reference in New Issue
Block a user