dev #2
@@ -0,0 +1,24 @@
|
|||||||
|
## What's Changed
|
||||||
|
|
||||||
|
### Nouvelles fonctionnalités
|
||||||
|
|
||||||
|
- **Nature des échanges** — les commentaires de transactions sont analysés et classés automatiquement en catégories : don & gratitude, alimentation, soin & bien-être, vêtements, culture & loisirs, événement, service & travaux, remboursement, migration, ticket, autre
|
||||||
|
- Distribution des catégories affichée dans le panneau latéral (barres proportionnelles sur les transactions commentées de la période) — chaque catégorie est cliquable pour dérouler la liste des transactions avec leur commentaire et montant
|
||||||
|
- Tooltip au survol des arcs en vue Flux : répartition des catégories + échantillon de commentaires bruts du corridor
|
||||||
|
- 76 % des transactions Ğ1v2 comportent un commentaire — le champ `remark` est désormais fetché depuis SubSquid
|
||||||
|
|
||||||
|
### Améliorations
|
||||||
|
|
||||||
|
- InfoPanel mis à jour : section *Nature des échanges* documentée
|
||||||
|
|
||||||
|
### Détails techniques
|
||||||
|
|
||||||
|
- Nouveau `src/data/commentParser.ts` — ~80 règles regex multilingues (FR/ES/CA/IT/EN/PT), 11 catégories, priorité ordonnée
|
||||||
|
- `SubsquidAdapter` : ajout de `comment { remark }` à la query GraphQL
|
||||||
|
- `Transaction` et `TransactionArc` : nouveaux champs `comment: string | null` et `category: TxCategory`
|
||||||
|
- `Corridor` : nouveaux champs `categories` (agrégées) et `comments` (échantillon jusqu'à 5)
|
||||||
|
- `PeriodStats` : nouveaux champs `categoryBreakdown` et `commentedCount`
|
||||||
|
- Zone de hit des arcs SVG élargie (+8 px) pour faciliter le survol
|
||||||
|
- Aucune nouvelle dépendance npm
|
||||||
|
|
||||||
|
**Full Changelog**: https://git.syoul.fr/geoflux/compare/v1.5.0...v1.6.0
|
||||||
+2
-1
@@ -117,7 +117,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
load(true);
|
load(true);
|
||||||
const interval = setInterval(() => load(false), 30_000);
|
const interval = setInterval(() => load(false), 120_000);
|
||||||
|
|
||||||
return () => { cancelled = true; clearInterval(interval); };
|
return () => { cancelled = true; clearInterval(interval); };
|
||||||
}, [periodDays, endpointVersion]);
|
}, [periodDays, endpointVersion]);
|
||||||
@@ -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 (
|
||||||
|
|||||||
+83
-12
@@ -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 */}
|
||||||
|
<g style={{ pointerEvents: 'all' }}>
|
||||||
{svgElements.arcElems.map(a => (
|
{svgElements.arcElems.map(a => (
|
||||||
<g key={`${a.ca.fromIdx}-${a.ca.toIdx}`} opacity={a.opacity}>
|
<g
|
||||||
<path
|
key={`${a.ca.fromIdx}-${a.ca.toIdx}`}
|
||||||
d={a.path}
|
opacity={hoveredArc && hoveredArc.key !== a.arcKey ? a.opacity * 0.4 : a.opacity}
|
||||||
fill="none"
|
onMouseEnter={() => setHoveredArc({ key: a.arcKey, x: a.midX, y: a.midY })}
|
||||||
stroke={a.stroke}
|
onMouseLeave={() => setHoveredArc(null)}
|
||||||
strokeWidth={a.strokeW}
|
style={{ cursor: 'default' }}
|
||||||
strokeLinecap="round"
|
>
|
||||||
/>
|
{/* Zone de hit invisible plus large */}
|
||||||
|
<path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(12, a.strokeW + 8)} />
|
||||||
|
<path d={a.path} fill="none" stroke={a.stroke} strokeWidth={a.strokeW} strokeLinecap="round" />
|
||||||
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
||||||
</g>
|
</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)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+22
-5
@@ -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, {
|
||||||
|
corridor: {
|
||||||
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
|
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
|
||||||
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
|
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
|
||||||
totalVolume: 0, count: 0,
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
export type TxCategory =
|
||||||
|
| 'migration'
|
||||||
|
| 'ticket'
|
||||||
|
| 'remboursement'
|
||||||
|
| 'don'
|
||||||
|
| 'alimentation'
|
||||||
|
| 'soin'
|
||||||
|
| 'vetements'
|
||||||
|
| 'culture'
|
||||||
|
| 'evenement'
|
||||||
|
| 'service'
|
||||||
|
| 'autre';
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS: Record<TxCategory, string> = {
|
||||||
|
migration: 'Migration',
|
||||||
|
ticket: 'Ticket',
|
||||||
|
remboursement:'Remboursement',
|
||||||
|
don: 'Don / Gratitude',
|
||||||
|
alimentation: 'Alimentation',
|
||||||
|
soin: 'Soin & bien-être',
|
||||||
|
vetements: 'Vêtements',
|
||||||
|
culture: 'Culture & loisirs',
|
||||||
|
evenement: 'Événement',
|
||||||
|
service: 'Service & travaux',
|
||||||
|
autre: 'Autre',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORY_COLORS: Record<TxCategory, string> = {
|
||||||
|
migration: '#4b5563',
|
||||||
|
ticket: '#6b7280',
|
||||||
|
remboursement:'#f59e0b',
|
||||||
|
don: '#ec4899',
|
||||||
|
alimentation: '#22c55e',
|
||||||
|
soin: '#06b6d4',
|
||||||
|
vetements: '#a78bfa',
|
||||||
|
culture: '#f97316',
|
||||||
|
evenement: '#eab308',
|
||||||
|
service: '#3b82f6',
|
||||||
|
autre: '#374151',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Règles de détection — ordre = priorité, première règle qui matche gagne
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Rule {
|
||||||
|
category: TxCategory;
|
||||||
|
patterns: RegExp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULES: Rule[] = [
|
||||||
|
{
|
||||||
|
category: 'migration',
|
||||||
|
patterns: [
|
||||||
|
/ğecko:csmigration/i,
|
||||||
|
/csmigration/i,
|
||||||
|
/migration\s*v[12]/i,
|
||||||
|
/\bğecko\b/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'ticket',
|
||||||
|
patterns: [
|
||||||
|
/\bticket\s+\d{6,}/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'remboursement',
|
||||||
|
patterns: [
|
||||||
|
/\bretour\b/i,
|
||||||
|
/\brendu\b/i,
|
||||||
|
/\bremboursement\b/i,
|
||||||
|
/\bdevolución\b/i,
|
||||||
|
/\bdevolucio\b/i,
|
||||||
|
/\brimborso\b/i,
|
||||||
|
/\brégul\b/i,
|
||||||
|
/\bregulariz/i,
|
||||||
|
/double\s*paiement/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'don',
|
||||||
|
patterns: [
|
||||||
|
/\bdon\b/i,
|
||||||
|
/\bdonación\b/i,
|
||||||
|
/\bdonazione\b/i,
|
||||||
|
/\bdonacio\b/i,
|
||||||
|
/\bcadeau\b/i,
|
||||||
|
/\bgratitud/i,
|
||||||
|
/\bgratitude\b/i,
|
||||||
|
/\bmerci\b/i,
|
||||||
|
/\bgracias\b/i,
|
||||||
|
/\bgràcies\b/i,
|
||||||
|
/\bgracies\b/i,
|
||||||
|
/\bobrigad/i,
|
||||||
|
/\bthank/i,
|
||||||
|
/\bgràcia/i,
|
||||||
|
/\bgrazie\b/i,
|
||||||
|
/\bgrazie\b/i,
|
||||||
|
/\bbienvenu/i,
|
||||||
|
/\bwelcome\b/i,
|
||||||
|
/\bchukurei\b/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'alimentation',
|
||||||
|
patterns: [
|
||||||
|
/\brepas\b/i,
|
||||||
|
/\bpaella\b/i,
|
||||||
|
/\bcrêpe\b/i,
|
||||||
|
/\bcrepe\b/i,
|
||||||
|
/\bfalafel\b/i,
|
||||||
|
/\bpain\b/i,
|
||||||
|
/\bpan\b/i,
|
||||||
|
/\bgâteau/i,
|
||||||
|
/\bgateau\b/i,
|
||||||
|
/\bgalleta/i,
|
||||||
|
/\bpastis\b/i,
|
||||||
|
/\bpastel\b/i,
|
||||||
|
/\bburger\b/i,
|
||||||
|
/\bkombucha\b/i,
|
||||||
|
/\bœuf/i,
|
||||||
|
/\boeufs?\b/i,
|
||||||
|
/\bhuevo/i,
|
||||||
|
/\bfromage\b/i,
|
||||||
|
/\bflan\b/i,
|
||||||
|
/\balgue/i,
|
||||||
|
/\blegum/i,
|
||||||
|
/\bfruits?\b/i,
|
||||||
|
/\bpomme/i,
|
||||||
|
/\blimonad/i,
|
||||||
|
/\blimonada\b/i,
|
||||||
|
/\blégumin/i,
|
||||||
|
/\bporro\b/i,
|
||||||
|
/\bcarbassa\b/i,
|
||||||
|
/\bsobrasada\b/i,
|
||||||
|
/\biarmelada\b/i,
|
||||||
|
/\bnispero/i,
|
||||||
|
/\bbizcocho\b/i,
|
||||||
|
/\bchocolat/i,
|
||||||
|
/\balmendra/i,
|
||||||
|
/\bincienso/i,
|
||||||
|
/\bincens/i,
|
||||||
|
/alimentation/i,
|
||||||
|
/\bépice/i,
|
||||||
|
/\bcava\b/i,
|
||||||
|
/\bvin\b/i,
|
||||||
|
/\baceit/i,
|
||||||
|
/huile\s*d.?olive/i,
|
||||||
|
/\bgerminado/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'soin',
|
||||||
|
patterns: [
|
||||||
|
/\bsoin\b/i,
|
||||||
|
/\bmassage\b/i,
|
||||||
|
/\bbaume\b/i,
|
||||||
|
/\bhuile\s*essenti/i,
|
||||||
|
/\btisane\b/i,
|
||||||
|
/\bterapia\b/i,
|
||||||
|
/\bthérapie\b/i,
|
||||||
|
/\bherboristerie\b/i,
|
||||||
|
/\bplante/i,
|
||||||
|
/\bhomeopat/i,
|
||||||
|
/\baromath/i,
|
||||||
|
/\breiki\b/i,
|
||||||
|
/\bacupunct/i,
|
||||||
|
/\bostéo/i,
|
||||||
|
/\bkinesio/i,
|
||||||
|
/\btirage\b/i,
|
||||||
|
/\bcart(e|as)\b/i,
|
||||||
|
/\bmandalas?\b/i,
|
||||||
|
/\bconsoude\b/i,
|
||||||
|
/\bsauge\b/i,
|
||||||
|
/\bromarin\b/i,
|
||||||
|
/\bserum\b/i,
|
||||||
|
/\bsérum\b/i,
|
||||||
|
/\bpeeling\b/i,
|
||||||
|
/\bbifasico\b/i,
|
||||||
|
/\bormus\b/i,
|
||||||
|
/eau\s*de\s*mer/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'vetements',
|
||||||
|
patterns: [
|
||||||
|
/\bjupe\b/i,
|
||||||
|
/\bpantalon\b/i,
|
||||||
|
/\bblouson\b/i,
|
||||||
|
/\bchaussure/i,
|
||||||
|
/\bvêtement/i,
|
||||||
|
/\bropa\b/i,
|
||||||
|
/\bcardigan\b/i,
|
||||||
|
/\bmanteau\b/i,
|
||||||
|
/\bchemise\b/i,
|
||||||
|
/\btricot\b/i,
|
||||||
|
/\blaine\b/i,
|
||||||
|
/\btissus?\b/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'culture',
|
||||||
|
patterns: [
|
||||||
|
/\blivre\b/i,
|
||||||
|
/\blivres\b/i,
|
||||||
|
/\blibro\b/i,
|
||||||
|
/\bmusique\b/i,
|
||||||
|
/\bmusica\b/i,
|
||||||
|
/\bmúsica\b/i,
|
||||||
|
/\bconcierto\b/i,
|
||||||
|
/\bconcert\b/i,
|
||||||
|
/\bcd\b/i,
|
||||||
|
/\bvídeo\b/i,
|
||||||
|
/\bvideo\b/i,
|
||||||
|
/\bpelícula\b/i,
|
||||||
|
/\bfilm\b/i,
|
||||||
|
/\bpoème\b/i,
|
||||||
|
/\bpoema\b/i,
|
||||||
|
/\bbd\b/i,
|
||||||
|
/\bdessin\b/i,
|
||||||
|
/\bnexus\b/i,
|
||||||
|
/\bnaruto\b/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'evenement',
|
||||||
|
patterns: [
|
||||||
|
/\bg1ntada\b/i,
|
||||||
|
/\bğ1ntada\b/i,
|
||||||
|
/\bmercat\b/i,
|
||||||
|
/\bmarché\b/i,
|
||||||
|
/\bjornadas?\b/i,
|
||||||
|
/\bfestival\b/i,
|
||||||
|
/\bfête\b/i,
|
||||||
|
/\bfiesta\b/i,
|
||||||
|
/\brassemblement\b/i,
|
||||||
|
/\bcampillo\b/i,
|
||||||
|
/\beutopia\b/i,
|
||||||
|
/\brencontre\b/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'service',
|
||||||
|
patterns: [
|
||||||
|
/\bservice\b/i,
|
||||||
|
/\batelier\b/i,
|
||||||
|
/\baccompagnement\b/i,
|
||||||
|
/\btravaux\b/i,
|
||||||
|
/\bbarnum\b/i,
|
||||||
|
/\baccueil\b/i,
|
||||||
|
/\bhébergement\b/i,
|
||||||
|
/\blogement\b/i,
|
||||||
|
/\bnuit\b/i,
|
||||||
|
/\bnuits\b/i,
|
||||||
|
/\bnit\b/i,
|
||||||
|
/\bnits\b/i,
|
||||||
|
/\bvisita\b/i,
|
||||||
|
/\bamortigua/i,
|
||||||
|
/\bamortisseur/i,
|
||||||
|
/\bréparation\b/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function parseComment(remark: string | null): TxCategory {
|
||||||
|
if (!remark) return 'autre';
|
||||||
|
const text = remark.trim();
|
||||||
|
if (!text) return 'autre';
|
||||||
|
|
||||||
|
for (const rule of RULES) {
|
||||||
|
if (rule.patterns.some((p) => p.test(text))) {
|
||||||
|
return rule.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'autre';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agrégation sur un tableau de catégories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CategoryCount {
|
||||||
|
category: TxCategory;
|
||||||
|
count: number;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateCategories(
|
||||||
|
items: { category: TxCategory; amount: number }[]
|
||||||
|
): CategoryCount[] {
|
||||||
|
const map = new Map<TxCategory, CategoryCount>();
|
||||||
|
for (const { category, amount } of items) {
|
||||||
|
if (!map.has(category)) map.set(category, { category, count: 0, volume: 0 });
|
||||||
|
const entry = map.get(category)!;
|
||||||
|
entry.count++;
|
||||||
|
entry.volume += amount;
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
+20
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import type { TxCategory } from './commentParser';
|
||||||
|
|
||||||
export interface Transaction {
|
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 { };
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface ServicesStatus {
|
|||||||
|
|
||||||
const TIMEOUT_MS = 8_000;
|
const TIMEOUT_MS = 8_000;
|
||||||
const SLOW_THRESHOLD_MS = 2_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> {
|
async function pingSubsquid(url: string): Promise<number> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user