feat: vue flux — arcs dirigés entre villes géolocalisées
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Nouveau type TransactionArc + buildCorridors + computeFlowStats - FlowMap : SVG overlay Leaflet, arcs bezier, flèches de direction, nœuds de villes cliquables - Clic sur une ville : arcs sortants orange, entrants teal, reste grisé - DataService : résolution géo des destinataires (toId) dans le même appel Cesium+ - useAnimation : expose visibleArcs filtré par frame - PeriodSelector : bouton toggle Heatmap / Flux - StatsPanel : stats flux (volume, top émetteurs, top récepteurs, balance nette) - App : state viewMode + focusCity, FlowMap conditionnel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import type { Transaction } from './mockData';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
const c = map.get(key)!;
|
||||
c.totalVolume += arc.amount;
|
||||
c.count++;
|
||||
}
|
||||
return [...map.values()].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,
|
||||
});
|
||||
}
|
||||
return arcs;
|
||||
}
|
||||
Reference in New Issue
Block a user