63f50d5762
ci/woodpecker/push/woodpecker Pipeline was successful
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>
197 lines
7.0 KiB
TypeScript
197 lines
7.0 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|