feat: vue flux — arcs dirigés entre villes géolocalisées
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:
syoul
2026-03-24 00:21:03 +01:00
parent ab72d8218b
commit 97ff22027c
7 changed files with 647 additions and 112 deletions
+61 -26
View File
@@ -13,12 +13,16 @@
*/
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys } from './adapters/CesiumAdapter';
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter';
import {
getTransactionsForPeriod,
computeStats,
type Transaction,
} from '../data/mockData';
import {
buildMockArcs,
type TransactionArc,
} from '../data/arcData';
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
@@ -43,15 +47,16 @@ async function getIdentityKeyMap(): Promise<Map<string, string>> {
}
async function fetchLiveTransactions(periodDays: number): Promise<{
geolocated: Transaction[];
totalCount: number;
totalVolume: number;
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);
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
@@ -63,15 +68,16 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
console.warn('Identity key map indisponible :', err);
}
// Clés Duniter uniques des émetteurs
const duniterKeys = [...new Set(
rawTransfers.map((t) => keyMap.get(t.fromId)).filter(Boolean) as string[]
)];
// Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+)
const allDuniterKeys = [...new Set([
...rawTransfers.map((t) => keyMap.get(t.fromId)),
...rawTransfers.map((t) => keyMap.get(t.toId)),
].filter(Boolean) as string[])];
// Résolution géo par clé Duniter (_id Cesium+)
let geoMap = new Map<string, { lat: number; lng: number; city: string; countryCode: string }>();
try {
const profiles = await resolveGeoByKeys(duniterKeys);
const profiles = await resolveGeoByKeys(allDuniterKeys);
for (const [key, p] of profiles) {
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode });
}
@@ -79,28 +85,53 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
console.warn('Cesium+ indisponible :', err);
}
// Seules les transactions avec un profil géo entrent dans le heatmap
const geolocated: Transaction[] = [];
for (const t of rawTransfers) {
const duniterKey = keyMap.get(t.fromId);
if (!duniterKey) continue;
const geo = geoMap.get(duniterKey);
if (!geo) continue;
const geolocated: Transaction[] = [];
const arcs: TransactionArc[] = [];
for (const t of rawTransfers) {
const fromDuniterKey = keyMap.get(t.fromId);
if (!fromDuniterKey) continue;
const fromGeo = geoMap.get(fromDuniterKey);
if (!fromGeo) continue;
const fromCity = cleanCityName(fromGeo.city);
// Heatmap : émetteur géolocalisé
geolocated.push({
id: t.id,
timestamp: t.timestamp,
lat: geo.lat,
lng: geo.lng,
lat: fromGeo.lat,
lng: fromGeo.lng,
amount: t.amount,
city: geo.city,
countryCode: geo.countryCode,
city: fromCity,
countryCode: fromGeo.countryCode,
fromKey: t.fromId,
toKey: t.toId,
});
// Arc : les deux extrémités géolocalisées + villes différentes
const toDuniterKey = keyMap.get(t.toId);
if (!toDuniterKey) continue;
const toGeo = geoMap.get(toDuniterKey);
if (!toGeo) continue;
const toCity = cleanCityName(toGeo.city);
if (fromCity === toCity) continue;
arcs.push({
id: `${t.id}-arc`,
timestamp: t.timestamp,
amount: t.amount,
fromLat: fromGeo.lat, fromLng: fromGeo.lng,
fromCity, fromCountry: fromGeo.countryCode,
fromKey: t.fromId,
toLat: toGeo.lat, toLng: toGeo.lng,
toCity, toCountry: toGeo.countryCode,
toKey: t.toId,
});
}
return { geolocated, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
}
// ---------------------------------------------------------------------------
@@ -114,11 +145,12 @@ export interface PeriodStats {
}
export interface DataResult {
transactions: Transaction[]; // uniquement géolocalisées → heatmap
transactions: Transaction[]; // uniquement géolocalisées → heatmap
arcs: TransactionArc[]; // les deux extrémités géolocalisées → vue flux
stats: PeriodStats;
source: 'live' | 'mock';
currentUD: number; // valeur du DU courant en Ğ1
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
currentUD: number; // valeur du DU courant en Ğ1
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
}
export async function fetchData(periodDays: number): Promise<DataResult> {
@@ -126,8 +158,10 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
await new Promise((r) => setTimeout(r, 80));
const transactions = getTransactionsForPeriod(periodDays);
const base = computeStats(transactions);
const arcs = buildMockArcs(transactions);
return {
transactions,
arcs,
stats: { ...base, geoCount: transactions.length },
source: 'mock',
currentUD: 11.78,
@@ -135,7 +169,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
};
}
const [{ geolocated, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
fetchLiveTransactions(periodDays),
getCurrentUD(),
]);
@@ -143,6 +177,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
return {
transactions: geolocated,
arcs,
stats: {
totalVolume,
transactionCount: totalCount,