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:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user