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:
+61
-26
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user