feat: initialisation de ĞéoFlux — visualisation géographique Ğ1

- Carte Leaflet plein écran avec heatmap (OpenStreetMap, dark mode)
- Sélecteur de période 24h / 7j / 30j
- Panneau latéral : volume total, compteur de transactions, top 3 villes
- mockData.ts : 2 400 transactions simulées sur 24 villes FR/EU
- DataService.ts : abstraction prête pour branchement Subsquid/Ğ1v2
- Schémas Zod (g1.schema.ts) : validation runtime Duniter GVA + Cesium+
- Adaptateurs DuniterAdapter et CesiumAdapter (Ğ1v1, à migrer v2)
- Suite de tests Vitest : 43 tests, conformité schéma Ğ1 vérifiée

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-22 15:49:01 +01:00
commit d20d042bca
34 changed files with 6397 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
/**
* DataService — abstraction layer between the UI and data sources.
*
* Currently backed by mock data. To switch to the Subsquid GraphQL API:
* 1. Set `USE_LIVE_API = true` (or read from env: import.meta.env.VITE_USE_LIVE_API)
* 2. Fill in SUBSQUID_ENDPOINT with your indexer URL
* 3. Implement `fetchLiveTransactions` with a proper GraphQL query
*/
import {
getTransactionsForPeriod,
computeStats,
type Transaction,
} from '../data/mockData';
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const USE_LIVE_API = false;
const SUBSQUID_ENDPOINT = 'https://squid.subsquid.io/g1-indexer/graphql'; // placeholder
// ---------------------------------------------------------------------------
// GraphQL helpers (used when USE_LIVE_API = true)
// ---------------------------------------------------------------------------
interface GqlTransactionEdge {
node: {
id: string;
blockTimestamp: string;
amount: string;
issuer: string;
recipient: string;
issuerlat?: number;
issuerlng?: number;
city?: string;
};
}
async function fetchLiveTransactions(periodDays: number): Promise<Transaction[]> {
const since = new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000).toISOString();
const query = `
query GetTransactions($since: DateTime!) {
transfers(
where: { blockTimestamp_gte: $since }
orderBy: blockTimestamp_DESC
limit: 5000
) {
edges {
node {
id
blockTimestamp
amount
issuer
recipient
issuerlat
issuerlng
city
}
}
}
}
`;
const response = await fetch(SUBSQUID_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { since } }),
});
if (!response.ok) {
throw new Error(`GraphQL request failed: ${response.statusText}`);
}
const { data, errors } = await response.json();
if (errors?.length) {
throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`);
}
return (data.transfers.edges as GqlTransactionEdge[]).map((edge) => ({
id: edge.node.id,
timestamp: new Date(edge.node.blockTimestamp).getTime(),
lat: edge.node.issuerlat ?? 46.2276, // fallback: France centroid
lng: edge.node.issuerlng ?? 2.2137,
amount: parseFloat(edge.node.amount) / 100, // Ğ1 uses centimes
city: edge.node.city ?? 'Inconnue',
fromKey: edge.node.issuer,
toKey: edge.node.recipient,
}));
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface PeriodStats {
totalVolume: number;
transactionCount: number;
topCities: { name: string; volume: number; count: number }[];
}
export interface DataResult {
transactions: Transaction[];
stats: PeriodStats;
}
export async function fetchData(periodDays: number): Promise<DataResult> {
let transactions: Transaction[];
if (USE_LIVE_API) {
transactions = await fetchLiveTransactions(periodDays);
} else {
// Simulate async for drop-in replacement compatibility
await new Promise((r) => setTimeout(r, 120));
transactions = getTransactionsForPeriod(periodDays);
}
const stats = computeStats(transactions);
return { transactions, stats };
}
+116
View File
@@ -0,0 +1,116 @@
/**
* CesiumAdapter — interroge l'API Elasticsearch de Cesium+.
*
* Cesium+ est la couche sociale de Ğ1 : les membres y publient
* un profil optionnel avec pseudo, avatar, ville, et coordonnées GPS.
*
* API docs : https://github.com/duniter/cesium-plus-pod
* Endpoint public : https://g1.data.duniter.fr
*/
import { CesiumSearchResponseSchema, type CesiumProfile } from '../../schemas/g1.schema';
export const CESIUM_ENDPOINT = 'https://g1.data.duniter.fr';
export interface GeoProfile {
pubkey: string;
city: string;
lat: number;
lng: number;
}
/**
* Résout les coordonnées géographiques d'une liste de clés publiques.
* Les membres sans profil ou sans geoPoint sont filtrés.
*
* @param pubkeys - tableau de clés publiques Ğ1 (base58)
* @returns Map<pubkey, GeoProfile>
*/
export async function resolveGeoProfiles(
pubkeys: string[]
): Promise<Map<string, GeoProfile>> {
if (pubkeys.length === 0) return new Map();
// Elasticsearch multi-get (mget) — efficace en batch
const body = { ids: pubkeys };
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_mget`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Cesium+ → HTTP ${response.status}`);
}
const raw = await response.json();
const parsed = CesiumSearchResponseSchema.parse({ hits: { hits: raw.docs ?? [] } });
const result = new Map<string, GeoProfile>();
for (const hit of parsed.hits.hits) {
const src = hit._source;
if (src.geoPoint) {
result.set(hit._id, {
pubkey: hit._id,
city: src.city ?? 'Inconnue',
lat: src.geoPoint.lat,
lng: src.geoPoint.lon,
});
}
}
return result;
}
/**
* Recherche des membres Ğ1 avec profil géolocalisé dans un rayon donné.
* Utile pour initialiser la carte avec les membres actifs d'une région.
*/
export async function searchMembersInBoundingBox(opts: {
topLeft: { lat: number; lng: number };
bottomRight: { lat: number; lng: number };
size?: number;
}): Promise<GeoProfile[]> {
const query = {
size: opts.size ?? 200,
query: {
bool: {
filter: [
{ term: { '_source.socials.type': 'member' } },
{
geo_bounding_box: {
'_source.geoPoint': {
top_left: { lat: opts.topLeft.lat, lon: opts.topLeft.lng },
bottom_right: { lat: opts.bottomRight.lat, lon: opts.bottomRight.lng },
},
},
},
],
},
},
_source: ['title', 'city', 'geoPoint'],
};
const response = await fetch(`${CESIUM_ENDPOINT}/user/profile/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query),
});
if (!response.ok) {
throw new Error(`Cesium+ search → HTTP ${response.status}`);
}
const raw = await response.json();
const parsed = CesiumSearchResponseSchema.parse(raw);
return parsed.hits.hits
.filter((h): h is CesiumProfile & { _source: { geoPoint: NonNullable<CesiumProfile['_source']['geoPoint']> } } =>
h._source.geoPoint !== undefined
)
.map((h) => ({
pubkey: h._id,
city: h._source.city ?? 'Inconnue',
lat: h._source.geoPoint.lat,
lng: h._source.geoPoint.lon,
}));
}
+74
View File
@@ -0,0 +1,74 @@
/**
* DuniterAdapter — interroge un nœud Duniter via l'API GVA (GraphQL).
*
* GVA = GraphQL Verification API, disponible sur tous les nœuds Duniter v1.8+
* Documentation : https://duniter.org/fr/wiki/duniter/gva/
*/
import {
GvaResponseSchema,
parseOutputAmount,
parseOutputRecipient,
} from '../../schemas/g1.schema';
// Nœuds publics (en choisir un disponible)
export const DUNITER_NODES = [
'https://g1.duniter.org/gva',
'https://g1.le-sou.org/gva',
'https://duniter.g1.1000i100.fr/gva',
] as const;
const TX_QUERY = `
query GetRecentTxs($pubkey: PubKeyGva!) {
txsHistoryBc(pubkey: $pubkey, order: desc) {
both {
edges {
node {
currency
issuers
outputs
blockstampTime
comment
hash
}
}
}
}
}
`;
/**
* Récupère les transactions d'une clé publique Ğ1 depuis un nœud GVA.
* Retourne les données brutes validées — la jointure géo se fait dans DataService.
*/
export async function fetchTransactionsFromGva(
pubkey: string,
nodeUrl: string = DUNITER_NODES[0]
) {
const response = await fetch(nodeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: TX_QUERY, variables: { pubkey } }),
});
if (!response.ok) {
throw new Error(`GVA ${nodeUrl} → HTTP ${response.status}`);
}
const raw = await response.json();
// Validation Zod : lève une ZodError avec le détail si la réponse est inattendue
const parsed = GvaResponseSchema.parse(raw);
return parsed.data.txsHistoryBc.both.edges.map((edge) => {
const node = edge.node;
const firstOutput = node.outputs[0] ?? '0:0:SIG()';
return {
hash: node.hash ?? `${node.issuers[0]}-${node.blockstampTime}`,
timestamp: node.blockstampTime * 1000, // s → ms
amount: parseOutputAmount(firstOutput),
fromKey: node.issuers[0] ?? '',
toKey: parseOutputRecipient(firstOutput),
comment: node.comment ?? '',
};
});
}