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

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,
}));
}