6 Commits

Author SHA1 Message Date
syoul 3148d74331 feat: période personnalisable — plage de dates libre (from/to)
ci/woodpecker/push/woodpecker Pipeline was successful
- Nouveau type Period (sliding | range) dans src/types/period.ts
- PeriodSelector : bouton "Plage" avec deux date inputs natifs
- SubsquidAdapter : filtre $until en plus de $since dans la query GraphQL
- DataService, mockData, useAnimation, useUrlState : Period partout
- Sparkline : fromMs/toMs au lieu de periodDays
- StatsPanel : label adapté au mode range
- URL partageable : ?from=YYYY-MM-DD&to=YYYY-MM-DD
- Auto-refresh désactivé pour les plages entièrement dans le passé

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 15:17:18 +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 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
18 changed files with 1002 additions and 217 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
+14 -11
View File
@@ -15,9 +15,10 @@ 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(initialUrlState.period);
const [period, setPeriod] = useState<Period>(initialUrlState.period);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [arcs, setArcs] = useState<TransactionArc[]>([]);
const [stats, setStats] = useState<PeriodStats | null>(null);
@@ -52,14 +53,14 @@ export default function App() {
}
};
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
const animation = useAnimation(transactions, arcs, period, allTimestamps);
// Synchronise l'état dans l'URL (deep link / partage)
useUrlSync(periodDays, viewMode, focusCity);
useUrlSync(period, viewMode, focusCity);
const handlePeriodChange = (days: number) => {
const handlePeriodChange = (newPeriod: Period) => {
animation.deactivate();
setPeriodDays(days);
setPeriod(newPeriod);
};
const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
@@ -98,7 +99,7 @@ export default function App() {
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);
@@ -117,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, endpointVersion]);
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
@@ -143,7 +145,7 @@ export default function App() {
const statsPanelProps = {
stats: visibleStats,
loading,
periodDays,
period,
source,
currentUD,
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
@@ -152,6 +154,7 @@ export default function App() {
focusCity,
allTimestamps,
onEndpointChange: () => setEndpointVersion((v) => v + 1),
transactions,
};
return (
@@ -222,7 +225,7 @@ export default function App() {
{/* 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()}
+94 -40
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,12 +80,47 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
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">
<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">
@@ -74,45 +130,40 @@ export function EndpointPopover({ service, onClose, onSaved }: Props) {
<button onClick={onClose} className="text-[#4b5563] hover:text-white transition-colors text-xl leading-none">×</button>
</div>
{/* Nœuds connus */}
{/* 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) => {
const result = testResults.get(node.url);
const isActive = inputUrl === node.url;
return (
<div
key={node.url}
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 cursor-pointer transition-colors ${
isActive
? 'border-[#d4a843]/60 bg-[#d4a843]/5'
: 'border-[#1e1f2a] hover:border-[#2e2f3a]'
}`}
onClick={() => setInputUrl(node.url)}
>
<div className="min-w-0">
<p className="text-white text-sm font-medium truncate">{node.label}</p>
<p className="text-[#4b5563] text-xs font-mono truncate">{node.url}</p>
</div>
<div className="flex items-center gap-2 ml-3 shrink-0">
{result && (
<span className="text-xs font-mono text-[#6b7280]">
{dot(result.state)}
{result.latencyMs !== null && ` ${result.latencyMs} ms`}
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); runTest(node.url); }}
className="text-xs text-[#4b5563] hover:text-[#d4a843] transition-colors px-2 py-1 border border-[#2e2f3a] rounded-lg"
>
Tester
</button>
</div>
</div>
);
})}
{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)}
+87 -16
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 */}
{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)}
+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
+114 -61
View File
@@ -1,8 +1,9 @@
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';
@@ -10,50 +11,93 @@ interface PeriodSelectorProps {
geoPercent?: number | null;
}
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 }: 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>
@@ -61,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>
@@ -115,22 +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>
);
}
+20 -11
View File
@@ -2,30 +2,34 @@ import { useMemo } from 'react';
interface SparklineProps {
timestamps: number[];
periodDays: number;
fromMs: number;
toMs: number;
}
/**
* Mini bar-chart SVG affichant l'activité journalière sur la période.
* 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, periodDays }: SparklineProps) {
export function Sparkline({ timestamps, fromMs, toMs }: SparklineProps) {
const buckets = useMemo(() => {
if (timestamps.length === 0) return [];
const n = periodDays === 1 ? 24 : Math.min(periodDays, 30);
const now = Date.now();
const start = now - periodDays * 864e5;
const step = (periodDays * 864e5) / n;
const 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 - start) / step);
const i = Math.floor((ts - fromMs) / step);
if (i >= 0 && i < n) counts[i]++;
}
return counts;
}, [timestamps, periodDays]);
}, [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;
@@ -33,6 +37,11 @@ export function Sparkline({ timestamps, periodDays }: SparklineProps) {
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
@@ -58,8 +67,8 @@ export function Sparkline({ timestamps, periodDays }: SparklineProps) {
})}
</svg>
<div className="flex justify-between text-[10px] text-[#4b5563]">
<span>{periodDays === 1 ? '0h' : 'J-' + periodDays}</span>
<span>{periodDays === 1 ? 'maintenant' : 'aujourd\'hui'}</span>
<span>{fmtLabel(fromMs)}</span>
<span>{fmtLabel(toMs)}</span>
</div>
</div>
);
+81 -5
View File
@@ -1,14 +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;
@@ -19,6 +22,7 @@ interface StatsPanelProps {
onClose?: () => void;
onEndpointChange?: () => void;
allTimestamps?: number[];
transactions?: Transaction[];
}
const MEDALS = ['🥇', '🥈', '🥉'];
@@ -65,9 +69,14 @@ 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, period, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [], transactions = [] }: StatsPanelProps) {
const { subsquid, cesium, recheck } = useServiceStatus();
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
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
@@ -132,7 +141,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
}
</p>
{!animationLabel && allTimestamps.length > 0 && (
<Sparkline timestamps={allTimestamps} periodDays={periodDays} />
<Sparkline timestamps={allTimestamps} fromMs={from.getTime()} toMs={to.getTime()} />
)}
</div>
@@ -309,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
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, {
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;
+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);
}
+22 -4
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',
});
}
@@ -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
View File
@@ -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 ≈ 910 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];
+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();
+33 -9
View File
@@ -5,17 +5,34 @@
* Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour.
*
* Paramètres supportés :
* ?period=7&view=flow&city=Paris
* ?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: number; view: 'heatmap' | 'flow'; city: string | null } {
function parseInitialState(): { period: Period; view: 'heatmap' | 'flow'; city: string | null } {
const p = new URLSearchParams(window.location.search);
const period = parseInt(p.get('period') ?? '', 10);
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: Number.isFinite(period) && period >= 1 && period <= 365 ? period : 7,
view: p.get('view') === 'flow' ? 'flow' : 'heatmap',
city: p.get('city') ?? null,
period: { type: 'sliding', days: Number.isFinite(days) && days >= 1 ? days : 7 },
view,
city,
};
}
@@ -24,16 +41,23 @@ export const initialUrlState = parseInitialState();
/** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */
export function useUrlSync(
periodDays: number,
period: Period,
viewMode: 'heatmap' | 'flow',
focusCity: string | null,
) {
const key = periodKey(period);
useEffect(() => {
const p = new URLSearchParams();
if (periodDays !== 7) p.set('period', String(periodDays));
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);
}, [periodDays, viewMode, focusCity]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, viewMode, focusCity]);
}
+19 -7
View File
@@ -13,7 +13,9 @@
*/
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);
@@ -112,6 +115,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 +138,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 +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 {
@@ -203,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 {
@@ -220,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);
@@ -233,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,
+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);
}
+13 -7
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,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 {
@@ -77,6 +79,9 @@ const TRANSFERS_QUERY = `
name
}
}
comment {
remark
}
}
}
}
@@ -216,19 +221,19 @@ export interface FetchTransfersResult {
}
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(getSubsquidUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: TRANSFERS_QUERY,
variables: { since, limit },
variables: { since, until, limit },
}),
});
@@ -253,6 +258,7 @@ export async function fetchTransfers(
fromId: node.fromId ?? '',
toId: node.toId ?? '',
fromName: node.from?.linkedIdentity?.name ?? '',
comment: node.comment?.remark ?? null,
})),
};
}
+28
View File
@@ -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;
}