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:
116
src/services/adapters/CesiumAdapter.ts
Normal file
116
src/services/adapters/CesiumAdapter.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user