8e396cd331
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>
138 lines
5.2 KiB
TypeScript
138 lines
5.2 KiB
TypeScript
import type { Transaction } from './mockData';
|
|
import type { TxCategory } from './commentParser';
|
|
import { aggregateCategories } from './commentParser';
|
|
|
|
export interface TransactionArc {
|
|
id: string;
|
|
timestamp: number; // Unix ms
|
|
amount: number; // Ğ1
|
|
fromLat: number;
|
|
fromLng: number;
|
|
fromCity: string;
|
|
fromCountry: string;
|
|
fromKey: string;
|
|
toLat: number;
|
|
toLng: number;
|
|
toCity: string;
|
|
toCountry: string;
|
|
toKey: string;
|
|
comment: string | null;
|
|
category: TxCategory;
|
|
}
|
|
|
|
/** Corridor agrégé par paire de villes (fromCity → toCity). */
|
|
export interface Corridor {
|
|
fromCity: string;
|
|
fromLat: number;
|
|
fromLng: number;
|
|
fromCountry: string;
|
|
toCity: string;
|
|
toLat: number;
|
|
toLng: number;
|
|
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 {
|
|
totalVolume: number;
|
|
arcCount: number;
|
|
topEmitters: { city: string; volume: number; count: number; countryCode: string }[];
|
|
topReceivers: { city: string; volume: number; count: number; countryCode: string }[];
|
|
netBalance: { city: string; net: number; countryCode: string }[];
|
|
}
|
|
|
|
/** 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: Omit<Corridor, 'categories' | 'comments'>; items: TransactionArc[] }>();
|
|
for (const arc of arcs) {
|
|
const key = `${arc.fromCity}||${arc.toCity}`;
|
|
if (!map.has(key)) {
|
|
map.set(key, {
|
|
corridor: {
|
|
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
|
|
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
|
|
totalVolume: 0, count: 0,
|
|
},
|
|
items: [],
|
|
});
|
|
}
|
|
const entry = map.get(key)!;
|
|
entry.corridor.totalVolume += arc.amount;
|
|
entry.corridor.count++;
|
|
entry.items.push(arc);
|
|
}
|
|
|
|
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 {
|
|
const emitters = new Map<string, { volume: number; count: number; country: string }>();
|
|
const receivers = new Map<string, { volume: number; count: number; country: string }>();
|
|
|
|
for (const arc of arcs) {
|
|
if (!emitters.has(arc.fromCity)) emitters.set(arc.fromCity, { volume: 0, count: 0, country: arc.fromCountry });
|
|
if (!receivers.has(arc.toCity)) receivers.set(arc.toCity, { volume: 0, count: 0, country: arc.toCountry });
|
|
emitters.get(arc.fromCity)!.volume += arc.amount;
|
|
emitters.get(arc.fromCity)!.count++;
|
|
receivers.get(arc.toCity)!.volume += arc.amount;
|
|
receivers.get(arc.toCity)!.count++;
|
|
}
|
|
|
|
const allCities = new Set([...emitters.keys(), ...receivers.keys()]);
|
|
const netBalance = [...allCities].map(city => ({
|
|
city,
|
|
net: (receivers.get(city)?.volume ?? 0) - (emitters.get(city)?.volume ?? 0),
|
|
countryCode: emitters.get(city)?.country ?? receivers.get(city)?.country ?? '',
|
|
})).sort((a, b) => Math.abs(b.net) - Math.abs(a.net)).slice(0, 5);
|
|
|
|
return {
|
|
totalVolume: arcs.reduce((s, a) => s + a.amount, 0),
|
|
arcCount: arcs.length,
|
|
topEmitters: [...emitters.entries()].sort((a, b) => b[1].volume - a[1].volume).slice(0, 3)
|
|
.map(([city, d]) => ({ city, volume: d.volume, count: d.count, countryCode: d.country })),
|
|
topReceivers: [...receivers.entries()].sort((a, b) => b[1].volume - a[1].volume).slice(0, 3)
|
|
.map(([city, d]) => ({ city, volume: d.volume, count: d.count, countryCode: d.country })),
|
|
netBalance,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Génère des arcs mock : chaque transaction devient un arc dont le destinataire
|
|
* est une transaction aléatoire d'une ville différente.
|
|
*/
|
|
export function buildMockArcs(transactions: Transaction[]): TransactionArc[] {
|
|
if (transactions.length < 2) return [];
|
|
const arcs: TransactionArc[] = [];
|
|
for (let i = 0; i < transactions.length; i++) {
|
|
if (Math.random() > 0.55) continue; // ~55 % de couverture
|
|
const from = transactions[i];
|
|
let toIdx = Math.floor(Math.random() * transactions.length);
|
|
for (let tries = 0; tries < 8 && transactions[toIdx].city === from.city; tries++) {
|
|
toIdx = Math.floor(Math.random() * transactions.length);
|
|
}
|
|
const to = transactions[toIdx];
|
|
if (to.city === from.city) continue;
|
|
arcs.push({
|
|
id: `${from.id}-arc`,
|
|
timestamp: from.timestamp,
|
|
amount: from.amount,
|
|
fromLat: from.lat, fromLng: from.lng,
|
|
fromCity: from.city, fromCountry: from.countryCode,
|
|
fromKey: from.fromKey,
|
|
toLat: to.lat, toLng: to.lng,
|
|
toCity: to.city, toCountry: to.countryCode,
|
|
toKey: to.toKey,
|
|
comment: from.comment,
|
|
category: from.category,
|
|
});
|
|
}
|
|
return arcs;
|
|
}
|