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>
This commit is contained in:
syoul
2026-04-21 21:29:59 +02:00
parent 6b7591db32
commit 8e396cd331
10 changed files with 562 additions and 27 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
+1
View File
@@ -152,6 +152,7 @@ export default function App() {
focusCity, focusCity,
allTimestamps, allTimestamps,
onEndpointChange: () => setEndpointVersion((v) => v + 1), onEndpointChange: () => setEndpointVersion((v) => v + 1),
transactions,
}; };
return ( return (
+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 type { TransactionArc } from '../data/arcData';
import { buildCorridors } 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) // Leaflet default marker fix (Vite asset pipeline)
import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconUrl from 'leaflet/dist/images/marker-icon.png';
@@ -169,6 +171,10 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
interface ClusterArc { interface ClusterArc {
fromIdx: number; toIdx: number; fromIdx: number; toIdx: number;
totalVolume: number; count: 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>(); const clArcMap = new Map<string, ClusterArc>();
for (const c of corridors) { for (const c of corridors) {
@@ -176,12 +182,18 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
const ti = cityClusterIdx.get(c.toCity); const ti = cityClusterIdx.get(c.toCity);
if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré
const key = `${fi}||${ti}`; 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)!; const ca = clArcMap.get(key)!;
ca.totalVolume += c.totalVolume; ca.totalVolume += c.totalVolume;
ca.count += c.count; 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 --- // --- 4. Couleur de balance par cluster ---
const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1); 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' : isFocusTo ? '#00acc1'
: '#2e2f3a'; : '#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 { return {
idx, ca, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill, idx, ca, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`, 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]); }, [corridors, cityNodes, focusCity, tick, mapReady, clustered]);
const [popupIdx, setPopupIdx] = useState<number | null>(null); 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 // Ferme le popup et le tooltip sur déplacement/zoom
useEffect(() => { setPopupIdx(null); }, [tick]); useEffect(() => { setPopupIdx(null); setHoveredArc(null); }, [tick]);
// Handler de clic : ouvre/ferme le popup + focus // Handler de clic : ouvre/ferme le popup + focus
const handleNodeClick = (nodeIdx: number) => { const handleNodeClick = (nodeIdx: number) => {
@@ -303,18 +320,22 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
</defs> </defs>
{/* Arcs bezier */} {/* Arcs bezier */}
{svgElements.arcElems.map(a => ( <g style={{ pointerEvents: 'all' }}>
<g key={`${a.ca.fromIdx}-${a.ca.toIdx}`} opacity={a.opacity}> {svgElements.arcElems.map(a => (
<path <g
d={a.path} key={`${a.ca.fromIdx}-${a.ca.toIdx}`}
fill="none" opacity={hoveredArc && hoveredArc.key !== a.arcKey ? a.opacity * 0.4 : a.opacity}
stroke={a.stroke} onMouseEnter={() => setHoveredArc({ key: a.arcKey, x: a.midX, y: a.midY })}
strokeWidth={a.strokeW} onMouseLeave={() => setHoveredArc(null)}
strokeLinecap="round" style={{ cursor: 'default' }}
/> >
<polygon points={a.arrowPts} fill={a.arrowFill} /> {/* Zone de hit invisible plus large */}
</g> <path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(12, a.strokeW + 8)} />
))} <path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" />
<polygon points={a.arrowPts} fill={a.arrowFill} />
</g>
))}
</g>
{/* Nœuds de clusters (pointer-events activés uniquement ici) */} {/* Nœuds de clusters (pointer-events activés uniquement ici) */}
<g style={{ pointerEvents: 'all' }}> <g style={{ pointerEvents: 'all' }}>
@@ -347,6 +368,56 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
</svg> </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 */} {/* Bouton cluster / villes */}
<button <button
onClick={() => setClustered(c => !c)} onClick={() => setClustered(c => !c)}
+17
View File
@@ -127,6 +127,23 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
</Feature> </Feature>
</Section> </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"> <Section title="Overlay Dividende Universel">
<Feature icon="DU" name="Membres actifs géolocalisés"> <Feature icon="DU" name="Membres actifs géolocalisés">
Le bouton <Kbd>DU</Kbd> (à gauche de la carte) affiche en overlay les membres Ğ1 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 { PeriodStats } from '../services/DataService';
import type { FlowStats } from '../data/arcData'; import type { FlowStats } from '../data/arcData';
import { Sparkline } from './Sparkline'; import { Sparkline } from './Sparkline';
import { ServiceStatusDots } from './ServiceStatusDots'; import { ServiceStatusDots } from './ServiceStatusDots';
import { useServiceStatus } from '../hooks/useServiceStatus'; import { useServiceStatus } from '../hooks/useServiceStatus';
import { CATEGORY_LABELS, CATEGORY_COLORS, type TxCategory } from '../data/commentParser';
import type { Transaction } from '../data/mockData';
interface StatsPanelProps { interface StatsPanelProps {
stats: PeriodStats | null; stats: PeriodStats | null;
@@ -19,6 +21,7 @@ interface StatsPanelProps {
onClose?: () => void; onClose?: () => void;
onEndpointChange?: () => void; onEndpointChange?: () => void;
allTimestamps?: number[]; allTimestamps?: number[];
transactions?: Transaction[];
} }
const MEDALS = ['🥇', '🥈', '🥉']; 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 { subsquid, cesium, recheck } = useServiceStatus();
const [openCategory, setOpenCategory] = useState<TxCategory | null>(null);
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null); const prevStats = useRef<PeriodStats | null>(null);
@@ -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 */} {/* Footer */}
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5"> <div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
<p className="text-[#2e2f3a] text-xs text-center"> <p className="text-[#2e2f3a] text-xs text-center">
+25 -8
View File
@@ -1,4 +1,6 @@
import type { Transaction } from './mockData'; import type { Transaction } from './mockData';
import type { TxCategory } from './commentParser';
import { aggregateCategories } from './commentParser';
export interface TransactionArc { export interface TransactionArc {
id: string; id: string;
@@ -14,6 +16,8 @@ export interface TransactionArc {
toCity: string; toCity: string;
toCountry: string; toCountry: string;
toKey: string; toKey: string;
comment: string | null;
category: TxCategory;
} }
/** Corridor agrégé par paire de villes (fromCity → toCity). */ /** Corridor agrégé par paire de villes (fromCity → toCity). */
@@ -28,6 +32,8 @@ export interface Corridor {
toCountry: string; toCountry: string;
totalVolume: number; totalVolume: number;
count: number; count: number;
categories: { category: TxCategory; count: number; volume: number }[];
comments: string[]; // échantillon de commentaires bruts (max 5, non nuls)
} }
export interface FlowStats { export interface FlowStats {
@@ -40,21 +46,30 @@ export interface FlowStats {
/** Agrège les arcs individuels en corridors ville→ville, triés par volume. */ /** Agrège les arcs individuels en corridors ville→ville, triés par volume. */
export function buildCorridors(arcs: TransactionArc[]): Corridor[] { 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) { for (const arc of arcs) {
const key = `${arc.fromCity}||${arc.toCity}`; const key = `${arc.fromCity}||${arc.toCity}`;
if (!map.has(key)) { if (!map.has(key)) {
map.set(key, { map.set(key, {
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry, corridor: {
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry, fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
totalVolume: 0, count: 0, toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
totalVolume: 0, count: 0,
},
items: [],
}); });
} }
const c = map.get(key)!; const entry = map.get(key)!;
c.totalVolume += arc.amount; entry.corridor.totalVolume += arc.amount;
c.count++; 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 { export function computeFlowStats(arcs: TransactionArc[]): FlowStats {
@@ -114,6 +129,8 @@ export function buildMockArcs(transactions: Transaction[]): TransactionArc[] {
toLat: to.lat, toLng: to.lng, toLat: to.lat, toLng: to.lng,
toCity: to.city, toCountry: to.countryCode, toCity: to.city, toCountry: to.countryCode,
toKey: to.toKey, toKey: to.toKey,
comment: from.comment,
category: from.category,
}); });
} }
return arcs; 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 { export interface Transaction {
id: string; id: string;
timestamp: number; // Unix ms (entier) timestamp: number; // Unix ms (entier)
@@ -8,6 +10,8 @@ export interface Transaction {
countryCode: string; // ISO 3166-1 alpha-2, ex: "FR" countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars
toKey: string; toKey: string;
comment: string | null;
category: TxCategory;
} }
// French + European cities where Ğ1 is used // French + European cities where Ğ1 is used
@@ -79,6 +83,8 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
countryCode: 'FR', countryCode: 'FR',
fromKey: generateKey(), fromKey: generateKey(),
toKey: generateKey(), toKey: generateKey(),
comment: null,
category: 'autre',
}); });
} }
@@ -114,7 +120,20 @@ export function computeStats(transactions: Transaction[]) {
.slice(0, 3) .slice(0, 3)
.map(([name, data]) => ({ name, ...data })); .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 { }; export type { };
+9
View File
@@ -14,6 +14,7 @@
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter'; import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter'; import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
import { parseComment } from '../data/commentParser';
import { import {
getTransactionsForPeriod, getTransactionsForPeriod,
computeStats, computeStats,
@@ -112,6 +113,8 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
countryCode: fromGeo.countryCode, countryCode: fromGeo.countryCode,
fromKey: t.fromId, fromKey: t.fromId,
toKey: t.toId, toKey: t.toId,
comment: t.comment,
category: parseComment(t.comment),
}); });
// Arc : les deux extrémités géolocalisées + villes différentes // 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, toLat: toGeo.lat, toLng: toGeo.lng,
toCity, toCountry: toGeo.countryCode, toCity, toCountry: toGeo.countryCode,
toKey: t.toId, 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) transactionCount: number; // total blockchain (y compris non-géolocalisés)
geoCount: number; // transactions visibles sur la carte geoCount: number; // transactions visibles sur la carte
topCities: { name: string; volume: number; count: number; countryCode: string }[]; 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 { export interface DataResult {
@@ -233,6 +240,8 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
transactionCount: totalCount, transactionCount: totalCount,
geoCount: geolocated.length, geoCount: geolocated.length,
topCities: base.topCities, topCities: base.topCities,
categoryBreakdown: base.categoryBreakdown,
commentedCount: base.commentedCount,
}, },
source: 'live', source: 'live',
currentUD, currentUD,
+6
View File
@@ -29,6 +29,7 @@ const SubsquidTransferNodeSchema = z.object({
from: z.object({ from: z.object({
linkedIdentity: z.object({ name: z.string() }).nullable(), linkedIdentity: z.object({ name: z.string() }).nullable(),
}).nullable(), }).nullable(),
comment: z.object({ remark: z.string() }).nullable().optional(),
}); });
const SubsquidResponseSchema = z.object({ const SubsquidResponseSchema = z.object({
@@ -52,6 +53,7 @@ export interface RawTransfer {
fromId: string; fromId: string;
toId: string; toId: string;
fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide) fromName: string; // nom d'identité Ğ1 de l'émetteur (peut être vide)
comment: string | null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -77,6 +79,9 @@ const TRANSFERS_QUERY = `
name name
} }
} }
comment {
remark
}
} }
} }
} }
@@ -253,6 +258,7 @@ export async function fetchTransfers(
fromId: node.fromId ?? '', fromId: node.fromId ?? '',
toId: node.toId ?? '', toId: node.toId ?? '',
fromName: node.from?.linkedIdentity?.name ?? '', fromName: node.from?.linkedIdentity?.name ?? '',
comment: node.comment?.remark ?? null,
})), })),
}; };
} }