7 Commits

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:29:59 +02:00
13 changed files with 720 additions and 69 deletions
+24
View File
@@ -0,0 +1,24 @@
## What's Changed
### Nouvelles fonctionnalités
- **Nature des échanges** — les commentaires de transactions sont analysés et classés automatiquement en catégories : don & gratitude, alimentation, soin & bien-être, vêtements, culture & loisirs, événement, service & travaux, remboursement, migration, ticket, autre
- Distribution des catégories affichée dans le panneau latéral (barres proportionnelles sur les transactions commentées de la période) — chaque catégorie est cliquable pour dérouler la liste des transactions avec leur commentaire et montant
- Tooltip au survol des arcs en vue Flux : répartition des catégories + échantillon de commentaires bruts du corridor
- 76 % des transactions Ğ1v2 comportent un commentaire — le champ `remark` est désormais fetché depuis SubSquid
### Améliorations
- InfoPanel mis à jour : section *Nature des échanges* documentée
### Détails techniques
- Nouveau `src/data/commentParser.ts` — ~80 règles regex multilingues (FR/ES/CA/IT/EN/PT), 11 catégories, priorité ordonnée
- `SubsquidAdapter` : ajout de `comment { remark }` à la query GraphQL
- `Transaction` et `TransactionArc` : nouveaux champs `comment: string | null` et `category: TxCategory`
- `Corridor` : nouveaux champs `categories` (agrégées) et `comments` (échantillon jusqu'à 5)
- `PeriodStats` : nouveaux champs `categoryBreakdown` et `commentedCount`
- Zone de hit des arcs SVG élargie (+8 px) pour faciliter le survol
- Aucune nouvelle dépendance npm
**Full Changelog**: https://git.syoul.fr/geoflux/compare/v1.5.0...v1.6.0
+2 -1
View File
@@ -117,7 +117,7 @@ export default function App() {
};
load(true);
const interval = setInterval(() => load(false), 30_000);
const interval = setInterval(() => load(false), 120_000);
return () => { cancelled = true; clearInterval(interval); };
}, [periodDays, endpointVersion]);
@@ -152,6 +152,7 @@ export default function App() {
focusCity,
allTimestamps,
onEndpointChange: () => setEndpointVersion((v) => v + 1),
transactions,
};
return (
+85 -31
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
KNOWN_SUBSQUID_NODES,
@@ -8,6 +8,7 @@ import {
setSubsquidUrl,
setCesiumUrl,
} from '../services/EndpointConfig';
import { discoverSquidNodes, clearPeerCache } from '../services/PeerDiscovery';
import { testEndpoint } from '../hooks/useServiceStatus';
interface Props {
@@ -30,8 +31,11 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
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 runTest = async (url: string) => {
const testUrl = async (url: string) => {
setTestResults((prev) => new Map(prev).set(url, { url, state: 'testing', latencyMs: null }));
try {
const ms = await testEndpoint(service, url);
@@ -43,6 +47,23 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
}
};
// 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;
@@ -59,40 +80,22 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
return <span className="text-red-500"></span>;
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-2xl shadow-2xl w-full max-w-md mx-4 p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-white font-bold text-base">
Configurer {LABELS[service]}
</h2>
<button onClick={onClose} className="text-[#4b5563] hover:text-white transition-colors text-xl leading-none">×</button>
</div>
{/* Nœuds connus */}
<div className="space-y-2">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nœuds connus</p>
{knownNodes.map((node) => {
const result = testResults.get(node.url);
const isActive = inputUrl === node.url;
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
key={node.url}
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
isActive
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
}`}
onClick={() => setInputUrl(node.url)}
onClick={() => setInputUrl(url)}
>
<div className="min-w-0">
<p className="text-white text-sm font-medium truncate">{node.label}</p>
<p className="text-[#4b5563] text-xs font-mono truncate">{node.url}</p>
<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 && (
@@ -102,7 +105,7 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); runTest(node.url); }}
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
@@ -110,9 +113,57 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
</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>
@@ -125,7 +176,7 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
className="flex-1 bg-[#0a0b0f] border border-[#2e2f3a] rounded-xl px-3 py-2 text-white text-sm font-mono placeholder-[#2e2f3a] focus:outline-none focus:border-[#d4a843]/60 transition-colors"
/>
<button
onClick={() => runTest(inputUrl.trim())}
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"
>
@@ -134,7 +185,10 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
</div>
{(() => {
const result = testResults.get(inputUrl.trim());
if (!result || knownNodes.some((n) => n.url === inputUrl.trim())) return null;
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)}
+83 -12
View File
@@ -24,6 +24,8 @@ const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux v
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 */}
<g style={{ pointerEvents: 'all' }}>
{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"
/>
<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)}
+17
View File
@@ -127,6 +127,23 @@ 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
+73 -2
View File
@@ -1,9 +1,11 @@
import { useRef } from 'react';
import { useRef, useState } from 'react';
import type { PeriodStats } from '../services/DataService';
import type { FlowStats } from '../data/arcData';
import { Sparkline } from './Sparkline';
import { ServiceStatusDots } from './ServiceStatusDots';
import { useServiceStatus } from '../hooks/useServiceStatus';
import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser';
import type { Transaction } from '../data/mockData';
interface StatsPanelProps {
stats: PeriodStats | null;
@@ -19,6 +21,7 @@ interface StatsPanelProps {
onClose?: () => void;
onEndpointChange?: () => void;
allTimestamps?: number[];
transactions?: Transaction[];
}
const MEDALS = ['🥇', '🥈', '🥉'];
@@ -65,8 +68,9 @@ function CityRow({ city, volume, count, countryCode, accent }: {
);
}
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [] }: StatsPanelProps) {
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
const { subsquid, cesium, recheck } = useServiceStatus();
const [openCategory, setOpenCategory] = useState<TxCategory | null>(null);
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
@@ -309,6 +313,73 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
</>
)}
{/* Nature des échanges */}
{!loading && stats && stats.categoryBreakdown.length > 0 && stats.commentedCount > 0 && (
<div className="space-y-2 border-t border-[#1e1f2a] pt-3">
<div className="flex items-center justify-between">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Nature des échanges</p>
<p className="text-[#2e2f3a] text-[10px] font-mono">{stats.commentedCount} commentés</p>
</div>
<div className="space-y-1">
{stats.categoryBreakdown
.filter((c) => c.category !== 'migration' && c.category !== 'ticket')
.slice(0, 7)
.map((c) => {
const pct = Math.round((c.count / stats.commentedCount) * 100);
const isOpen = openCategory === c.category;
const detail = transactions.filter((t) => t.category === c.category && t.comment);
return (
<div key={c.category}>
<button
onClick={() => setOpenCategory(isOpen ? null : c.category)}
className="w-full text-left group"
>
<div className="flex items-center justify-between mb-0.5">
<span className={`text-[11px] transition-colors ${isOpen ? 'text-white' : 'text-[#9ca3af] group-hover:text-white'}`}>
{isOpen ? '▾' : '▸'} {CATEGORY_LABELS[c.category]}
</span>
<span className="text-[#4b5563] text-[10px] font-mono">{c.count} · {pct}%</span>
</div>
<div className="w-full bg-[#1e1f2a] rounded-full h-1">
<div
className="h-1 rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: CATEGORY_COLORS[c.category] }}
/>
</div>
</button>
{isOpen && (
<div className="mt-1.5 mb-1 bg-[#0a0b0f] border border-[#1e1f2a] rounded-lg overflow-hidden">
{detail.length === 0 ? (
<p className="text-[#4b5563] text-[10px] px-3 py-2">Aucun commentaire disponible.</p>
) : (
<div className="max-h-48 overflow-y-auto divide-y divide-[#1e1f2a]">
{detail.slice(0, 30).map((t) => (
<div key={t.id} className="px-3 py-1.5 flex items-start justify-between gap-2">
<p className="text-[#9ca3af] text-[10px] italic leading-snug flex-1 min-w-0 truncate">
"{t.comment}"
</p>
<span className="text-[#d4a843] text-[10px] font-mono shrink-0">
{t.amount.toLocaleString('fr-FR', { maximumFractionDigits: 1 })} Ğ1
</span>
</div>
))}
{detail.length > 30 && (
<p className="text-[#4b5563] text-[10px] px-3 py-1.5 text-center">
+{detail.length - 30} autres
</p>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Footer */}
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
<p className="text-[#2e2f3a] text-xs text-center">
+22 -5
View File
@@ -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, {
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;
+300
View File
@@ -0,0 +1,300 @@
export type TxCategory =
| 'migration'
| 'ticket'
| 'remboursement'
| 'don'
| 'alimentation'
| 'soin'
| 'vetements'
| 'culture'
| 'evenement'
| 'service'
| 'autre';
export const CATEGORY_LABELS: Record<TxCategory, string> = {
migration: 'Migration',
ticket: 'Ticket',
remboursement:'Remboursement',
don: 'Don / Gratitude',
alimentation: 'Alimentation',
soin: 'Soin & bien-être',
vetements: 'Vêtements',
culture: 'Culture & loisirs',
evenement: 'Événement',
service: 'Service & travaux',
autre: 'Autre',
};
export const CATEGORY_COLORS: Record<TxCategory, string> = {
migration: '#4b5563',
ticket: '#6b7280',
remboursement:'#f59e0b',
don: '#ec4899',
alimentation: '#22c55e',
soin: '#06b6d4',
vetements: '#a78bfa',
culture: '#f97316',
evenement: '#eab308',
service: '#3b82f6',
autre: '#374151',
};
// ---------------------------------------------------------------------------
// Règles de détection — ordre = priorité, première règle qui matche gagne
// ---------------------------------------------------------------------------
interface Rule {
category: TxCategory;
patterns: RegExp[];
}
const RULES: Rule[] = [
{
category: 'migration',
patterns: [
/ğecko:csmigration/i,
/csmigration/i,
/migration\s*v[12]/i,
/\bğecko\b/i,
],
},
{
category: 'ticket',
patterns: [
/\bticket\s+\d{6,}/i,
],
},
{
category: 'remboursement',
patterns: [
/\bretour\b/i,
/\brendu\b/i,
/\bremboursement\b/i,
/\bdevolución\b/i,
/\bdevolucio\b/i,
/\brimborso\b/i,
/\brégul\b/i,
/\bregulariz/i,
/double\s*paiement/i,
],
},
{
category: 'don',
patterns: [
/\bdon\b/i,
/\bdonación\b/i,
/\bdonazione\b/i,
/\bdonacio\b/i,
/\bcadeau\b/i,
/\bgratitud/i,
/\bgratitude\b/i,
/\bmerci\b/i,
/\bgracias\b/i,
/\bgràcies\b/i,
/\bgracies\b/i,
/\bobrigad/i,
/\bthank/i,
/\bgràcia/i,
/\bgrazie\b/i,
/\bgrazie\b/i,
/\bbienvenu/i,
/\bwelcome\b/i,
/\bchukurei\b/i,
],
},
{
category: 'alimentation',
patterns: [
/\brepas\b/i,
/\bpaella\b/i,
/\bcrêpe\b/i,
/\bcrepe\b/i,
/\bfalafel\b/i,
/\bpain\b/i,
/\bpan\b/i,
/\bgâteau/i,
/\bgateau\b/i,
/\bgalleta/i,
/\bpastis\b/i,
/\bpastel\b/i,
/\bburger\b/i,
/\bkombucha\b/i,
/\bœuf/i,
/\boeufs?\b/i,
/\bhuevo/i,
/\bfromage\b/i,
/\bflan\b/i,
/\balgue/i,
/\blegum/i,
/\bfruits?\b/i,
/\bpomme/i,
/\blimonad/i,
/\blimonada\b/i,
/\blégumin/i,
/\bporro\b/i,
/\bcarbassa\b/i,
/\bsobrasada\b/i,
/\biarmelada\b/i,
/\bnispero/i,
/\bbizcocho\b/i,
/\bchocolat/i,
/\balmendra/i,
/\bincienso/i,
/\bincens/i,
/alimentation/i,
/\bépice/i,
/\bcava\b/i,
/\bvin\b/i,
/\baceit/i,
/huile\s*d.?olive/i,
/\bgerminado/i,
],
},
{
category: 'soin',
patterns: [
/\bsoin\b/i,
/\bmassage\b/i,
/\bbaume\b/i,
/\bhuile\s*essenti/i,
/\btisane\b/i,
/\bterapia\b/i,
/\bthérapie\b/i,
/\bherboristerie\b/i,
/\bplante/i,
/\bhomeopat/i,
/\baromath/i,
/\breiki\b/i,
/\bacupunct/i,
/\bostéo/i,
/\bkinesio/i,
/\btirage\b/i,
/\bcart(e|as)\b/i,
/\bmandalas?\b/i,
/\bconsoude\b/i,
/\bsauge\b/i,
/\bromarin\b/i,
/\bserum\b/i,
/\bsérum\b/i,
/\bpeeling\b/i,
/\bbifasico\b/i,
/\bormus\b/i,
/eau\s*de\s*mer/i,
],
},
{
category: 'vetements',
patterns: [
/\bjupe\b/i,
/\bpantalon\b/i,
/\bblouson\b/i,
/\bchaussure/i,
/\bvêtement/i,
/\bropa\b/i,
/\bcardigan\b/i,
/\bmanteau\b/i,
/\bchemise\b/i,
/\btricot\b/i,
/\blaine\b/i,
/\btissus?\b/i,
],
},
{
category: 'culture',
patterns: [
/\blivre\b/i,
/\blivres\b/i,
/\blibro\b/i,
/\bmusique\b/i,
/\bmusica\b/i,
/\bmúsica\b/i,
/\bconcierto\b/i,
/\bconcert\b/i,
/\bcd\b/i,
/\bvídeo\b/i,
/\bvideo\b/i,
/\bpelícula\b/i,
/\bfilm\b/i,
/\bpoème\b/i,
/\bpoema\b/i,
/\bbd\b/i,
/\bdessin\b/i,
/\bnexus\b/i,
/\bnaruto\b/i,
],
},
{
category: 'evenement',
patterns: [
/\bg1ntada\b/i,
/\bğ1ntada\b/i,
/\bmercat\b/i,
/\bmarché\b/i,
/\bjornadas?\b/i,
/\bfestival\b/i,
/\bfête\b/i,
/\bfiesta\b/i,
/\brassemblement\b/i,
/\bcampillo\b/i,
/\beutopia\b/i,
/\brencontre\b/i,
],
},
{
category: 'service',
patterns: [
/\bservice\b/i,
/\batelier\b/i,
/\baccompagnement\b/i,
/\btravaux\b/i,
/\bbarnum\b/i,
/\baccueil\b/i,
/\bhébergement\b/i,
/\blogement\b/i,
/\bnuit\b/i,
/\bnuits\b/i,
/\bnit\b/i,
/\bnits\b/i,
/\bvisita\b/i,
/\bamortigua/i,
/\bamortisseur/i,
/\bréparation\b/i,
],
},
];
export function parseComment(remark: string | null): TxCategory {
if (!remark) return 'autre';
const text = remark.trim();
if (!text) return 'autre';
for (const rule of RULES) {
if (rule.patterns.some((p) => p.test(text))) {
return rule.category;
}
}
return 'autre';
}
// ---------------------------------------------------------------------------
// Agrégation sur un tableau de catégories
// ---------------------------------------------------------------------------
export interface CategoryCount {
category: TxCategory;
count: number;
volume: number;
}
export function aggregateCategories(
items: { category: TxCategory; amount: number }[]
): CategoryCount[] {
const map = new Map<TxCategory, CategoryCount>();
for (const { category, amount } of items) {
if (!map.has(category)) map.set(category, { category, count: 0, volume: 0 });
const entry = map.get(category)!;
entry.count++;
entry.volume += amount;
}
return [...map.values()].sort((a, b) => b.count - a.count);
}
+20 -1
View File
@@ -1,3 +1,5 @@
import type { TxCategory } from './commentParser';
export interface Transaction {
id: string;
timestamp: number; // Unix ms (entier)
@@ -8,6 +10,8 @@ export interface Transaction {
countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars
toKey: string;
comment: string | null;
category: TxCategory;
}
// French + European cities where Ğ1 is used
@@ -79,6 +83,8 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
countryCode: 'FR',
fromKey: generateKey(),
toKey: generateKey(),
comment: null,
category: 'autre',
});
}
@@ -114,7 +120,20 @@ export function computeStats(transactions: Transaction[]) {
.slice(0, 3)
.map(([name, data]) => ({ name, ...data }));
return { totalVolume, transactionCount, topCities };
const catMap = new Map<import('./commentParser').TxCategory, { count: number; volume: number }>();
let commentedCount = 0;
for (const tx of transactions) {
if (tx.comment) commentedCount++;
const entry = catMap.get(tx.category) ?? { count: 0, volume: 0 };
entry.count++;
entry.volume += tx.amount;
catMap.set(tx.category, entry);
}
const categoryBreakdown = [...catMap.entries()]
.map(([category, v]) => ({ category, ...v }))
.sort((a, b) => b.count - a.count);
return { totalVolume, transactionCount, topCities, categoryBreakdown, commentedCount };
}
export type { };
+1 -1
View File
@@ -17,7 +17,7 @@ export interface ServicesStatus {
const TIMEOUT_MS = 8_000;
const SLOW_THRESHOLD_MS = 2_000;
const POLL_INTERVAL_MS = 30_000;
const POLL_INTERVAL_MS = 60_000;
async function pingSubsquid(url: string): Promise<number> {
const controller = new AbortController();
+9
View File
@@ -14,6 +14,7 @@
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
import { parseComment } from '../data/commentParser';
import {
getTransactionsForPeriod,
computeStats,
@@ -112,6 +113,8 @@ 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
@@ -133,6 +136,8 @@ 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),
});
}
@@ -192,6 +197,8 @@ export interface PeriodStats {
transactionCount: number; // total blockchain (y compris non-géolocalisés)
geoCount: number; // transactions visibles sur la carte
topCities: { name: string; volume: number; count: number; countryCode: string }[];
categoryBreakdown: { category: import('../data/commentParser').TxCategory; count: number; volume: number }[];
commentedCount: number; // nb de transactions avec un commentaire
}
export interface DataResult {
@@ -233,6 +240,8 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
transactionCount: totalCount,
geoCount: geolocated.length,
topCities: base.topCities,
categoryBreakdown: base.categoryBreakdown,
commentedCount: base.commentedCount,
},
source: 'live',
currentUD,
+62
View File
@@ -0,0 +1,62 @@
const DUNITER_RPC = 'https://rpc.duniter.org';
const CACHE_KEY = 'geoflux-peers-v1';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
interface PeerCache {
urls: string[];
fetchedAt: number;
}
function normalizeSquidUrl(raw: string): string {
const url = raw.replace(/\/$/, '');
return url.endsWith('/v1/graphql') ? url : `${url}/v1/graphql`;
}
export async function discoverSquidNodes(): Promise<string[]> {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const parsed: PeerCache = JSON.parse(cached);
if (Date.now() - parsed.fetchedAt < CACHE_TTL_MS) return parsed.urls;
}
} catch { /* ignore */ }
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8_000);
try {
const res = await fetch(DUNITER_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'duniter_peerings', params: [], id: 1 }),
signal: controller.signal,
});
const data = await res.json();
const peers: { peer_id: string; endpoints: { protocol: string; address: string }[] }[] =
data?.result?.peerings ?? [];
const seen = new Set<string>();
const urls: string[] = [];
for (const peer of peers) {
for (const ep of peer.endpoints ?? []) {
if (ep.protocol === 'squid' && ep.address) {
const normalized = normalizeSquidUrl(ep.address);
if (!seen.has(normalized)) {
seen.add(normalized);
urls.push(normalized);
}
}
}
}
localStorage.setItem(CACHE_KEY, JSON.stringify({ urls, fetchedAt: Date.now() }));
return urls;
} catch {
return [];
} finally {
clearTimeout(timer);
}
}
export function clearPeerCache(): void {
localStorage.removeItem(CACHE_KEY);
}
+6
View File
@@ -29,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({
@@ -52,6 +53,7 @@ export interface RawTransfer {
fromId: string;
toId: string;
fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide)
comment: string | null;
}
// ---------------------------------------------------------------------------
@@ -77,6 +79,9 @@ const TRANSFERS_QUERY = `
name
}
}
comment {
remark
}
}
}
}
@@ -253,6 +258,7 @@ export async function fetchTransfers(
fromId: node.fromId ?? '',
toId: node.toId ?? '',
fromName: node.from?.linkedIdentity?.name ?? '',
comment: node.comment?.remark ?? null,
})),
};
}