Files
g1flux/src/services/DataService.ts
T
syoul 63f50d5762
ci/woodpecker/push/woodpecker Pipeline was successful
feat: géolocaliser les comptes non-membres via Cesium+
Pour les fromId/toId absents du keyMap WoT, applique ss58ToDuniterKey
directement pour tenter un lookup Cesium+. Les non-membres ayant un
profil géolocalisé (ex: comptes portefeuille avec ville renseignée)
apparaissent désormais dans le flux animé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:38:31 +01:00

197 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* DataService — couche d'abstraction entre l'UI et les sources de données.
*
* Mode mock (USE_LIVE_API = false) : données simulées, aucun appel réseau.
* Mode live (USE_LIVE_API = true) : données réelles Ğ1v2.
* - Transactions : Subsquid indexer https://squidv2s.syoul.fr/v1/graphql
* - Géolocalisation : Cesium+ https://g1.data.e-is.pro
* → lookup par clé Duniter (_id) via conversion SS58 → base58 Ed25519
* → membres migrés v1→v2 : clé genesis (previousId) = _id Cesium+
* → carte d'identité (keyMap) mise en cache 10 min, 1 seule requête
*
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey } from './adapters/SubsquidAdapter';
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';
// Cache du DU courant, valide 1 heure (le DU change tous les ~6 mois)
let udCache: { value: number; expiresAt: number } | null = null;
async function getCurrentUD(): Promise<number> {
if (udCache && Date.now() < udCache.expiresAt) return udCache.value;
const value = await fetchCurrentUD();
udCache = { value, expiresAt: Date.now() + 60 * 60 * 1000 };
return value;
}
// Cache de la carte identité SS58→DuniterKey, valide 10 minutes
let keyMapCache: { map: Map<string, string>; expiresAt: number } | null = null;
async function getIdentityKeyMap(): Promise<Map<string, string>> {
if (keyMapCache && Date.now() < keyMapCache.expiresAt) return keyMapCache.map;
const map = await buildIdentityKeyMap();
keyMapCache = { map, expiresAt: Date.now() + 10 * 60 * 1000 };
return map;
}
async function fetchLiveTransactions(periodDays: number): Promise<{
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: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
// Carte SS58 courant → clé Duniter (= _id Cesium+)
let keyMap = new Map<string, string>();
try {
keyMap = await getIdentityKeyMap();
} catch (err) {
console.warn('Identity key map indisponible :', err);
}
// Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+)
// Pour les membres WoT : via keyMap (genesis key = _id Cesium+)
// Pour les non-membres : conversion directe SS58 → Duniter key
const resolveKey = (ss58: string): string =>
keyMap.get(ss58) ?? ss58ToDuniterKey(ss58);
const allDuniterKeys = [...new Set([
...rawTransfers.map((t) => t.fromId ? resolveKey(t.fromId) : undefined),
...rawTransfers.map((t) => t.toId ? resolveKey(t.toId) : undefined),
].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(allDuniterKeys);
for (const [key, p] of profiles) {
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode });
}
} catch (err) {
console.warn('Cesium+ indisponible :', err);
}
const geolocated: Transaction[] = [];
const arcs: TransactionArc[] = [];
for (const t of rawTransfers) {
const fromDuniterKey = t.fromId ? resolveKey(t.fromId) : undefined;
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: fromGeo.lat,
lng: fromGeo.lng,
amount: t.amount,
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 = t.toId ? resolveKey(t.toId) : undefined;
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, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface PeriodStats {
totalVolume: number;
transactionCount: number; // total blockchain (y compris non-géolocalisés)
geoCount: number; // transactions visibles sur la carte
topCities: { name: string; volume: number; count: number; countryCode: string }[];
}
export interface DataResult {
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)
}
export async function fetchData(periodDays: number): Promise<DataResult> {
if (!USE_LIVE_API) {
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,
allTimestamps: transactions.map((t) => t.timestamp),
};
}
const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
fetchLiveTransactions(periodDays),
getCurrentUD(),
]);
const base = computeStats(geolocated);
return {
transactions: geolocated,
arcs,
stats: {
totalVolume,
transactionCount: totalCount,
geoCount: geolocated.length,
topCities: base.topCities,
},
source: 'live',
currentUD,
allTimestamps,
};
}