/** * 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. * * Endpoint actif : https://g1.data.e-is.pro (59 841 profils, vérifié le 2026-03-22) * g1.data.duniter.fr est hors ligne depuis l'arrêt de Ğ1v1. * * En Ğ1v2 les clés SS58 ont changé : la recherche se fait par nom d'identité * (identity.name depuis Subsquid) et non plus par clé publique. */ import { z } from 'zod'; export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro'; export interface GeoProfile { name: string; // nom d'identité Ğ1 (ex: "Anikka") city: string; lat: number; lng: number; } // geoPoint accepte n'importe quel type — Cesium+ utilise plusieurs formats ES geo_point const HitSchema = z.object({ _id: z.string(), _source: z.object({ title: z.string().optional(), city: z.string().optional(), geoPoint: z.unknown().optional(), }), }); const SearchResponseSchema = z.object({ hits: z.object({ hits: z.array(HitSchema), }), }); /** Normalise les différents formats Elasticsearch geo_point → {lat, lng} ou null */ function parseGeoPoint(raw: unknown): { lat: number; lng: number } | null { if (raw == null) return null; // Format objet { lat, lon } if (typeof raw === 'object' && !Array.isArray(raw)) { const g = raw as Record; const lat = Number(g.lat); const lon = Number(g.lon); if (isFinite(lat) && isFinite(lon) && lat >= -90 && lat <= 90) return { lat, lng: lon }; } // Format string "lat,lon" if (typeof raw === 'string') { const [a, b] = raw.split(',').map((s) => Number(s.trim())); if (isFinite(a) && isFinite(b) && a >= -90 && a <= 90) return { lat: a, lng: b }; } // Format tableau [lon, lat] (ES geo_point array) if (Array.isArray(raw) && raw.length === 2) { const lon = Number(raw[0]); const lat = Number(raw[1]); if (isFinite(lat) && isFinite(lon) && lat >= -90 && lat <= 90) return { lat, lng: lon }; } return null; } /** * Résout les coordonnées par clé Duniter (Cesium+ _id). * Plus fiable que par nom car la clé est unique et indépendante de la casse. * Fonctionne pour les membres migrés v1→v2 (clé genesis = _id Cesium+). */ export async function resolveGeoByKeys( duniterKeys: string[] ): Promise> { const unique = [...new Set(duniterKeys.filter(Boolean))]; if (unique.length === 0) return new Map(); const query = { size: unique.length, query: { ids: { values: unique } }, _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+ HTTP ${response.status}`); const raw = await response.json(); const parsed = SearchResponseSchema.parse(raw); const result = new Map(); for (const hit of parsed.hits.hits) { const src = hit._source; const geo = parseGeoPoint(src.geoPoint); if (!geo) continue; result.set(hit._id, { name: src.title ?? '', city: src.city ?? 'Inconnue', lat: geo.lat, lng: geo.lng, }); } return result; } /** * Résout les coordonnées de plusieurs membres Ğ1 par leur nom d'identité. * Envoie une requête Elasticsearch multi-terms en un seul appel. * * @param names - noms d'identité uniques (depuis SubsquidAdapter RawTransfer.fromName) * @returns Map */ export async function resolveGeoByNames( names: string[] ): Promise> { const unique = [...new Set(names.filter(Boolean))]; if (unique.length === 0) return new Map(); const query = { // Dépasser la limite par défaut : plusieurs profils peuvent avoir le même prénom size: unique.length * 3, query: { bool: { must: [ // Champ "title" analysé (lowercase tokens) — doit envoyer en minuscules { terms: { title: unique.map((n) => n.toLowerCase()) } }, ], filter: [ { exists: { field: 'geoPoint' } }, ], }, }, _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+ HTTP ${response.status}`); } const raw = await response.json(); const parsed = SearchResponseSchema.parse(raw); const result = new Map(); for (const hit of parsed.hits.hits) { const src = hit._source; const geo = parseGeoPoint(src.geoPoint); if (geo && src.title) { result.set(src.title.toLowerCase(), { name: src.title, city: src.city ?? 'Inconnue', lat: geo.lat, lng: geo.lng, }); } } return result; }