/** * 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'; import { getCesiumUrl } from '../EndpointConfig'; export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro'; export interface GeoProfile { name: string; // nom d'identité Ğ1 (ex: "Anikka") city: string; countryCode: string; // ISO 3166-1 alpha-2, ex: "FR" lat: number; lng: number; } // Détection de pays par bounding box (pays présents dans la communauté Ğ1) const COUNTRY_BOXES: { code: string; latMin: number; latMax: number; lngMin: number; lngMax: number }[] = [ // Petits pays d'abord : leurs bounding boxes chevauchent celle de la France { code: 'LU', latMin: 49.4, latMax: 50.2, lngMin: 5.7, lngMax: 6.5 }, { code: 'BE', latMin: 49.5, latMax: 51.5, lngMin: 2.5, lngMax: 6.4 }, { code: 'CH', latMin: 45.8, latMax: 47.8, lngMin: 5.9, lngMax: 10.5 }, { code: 'NL', latMin: 50.7, latMax: 53.6, lngMin: 3.3, lngMax: 7.2 }, { code: 'DE', latMin: 47.3, latMax: 55.1, lngMin: 6.0, lngMax: 15.0 }, { code: 'FR', latMin: 41.3, latMax: 51.1, lngMin: -5.1, lngMax: 9.6 }, { code: 'ES', latMin: 35.9, latMax: 43.8, lngMin: -9.3, lngMax: 4.3 }, { code: 'PT', latMin: 36.8, latMax: 42.2, lngMin: -9.5, lngMax: -6.2 }, { code: 'IT', latMin: 36.6, latMax: 47.1, lngMin: 6.6, lngMax: 18.5 }, { code: 'GB', latMin: 49.9, latMax: 60.9, lngMin: -8.2, lngMax: 1.8 }, { code: 'MA', latMin: 27.6, latMax: 35.9, lngMin: -13.2, lngMax: -1.0 }, { code: 'TN', latMin: 30.2, latMax: 37.5, lngMin: 7.5, lngMax: 11.6 }, { code: 'SN', latMin: 12.3, latMax: 16.7, lngMin: -17.5, lngMax: -11.4 }, { code: 'CA', latMin: 41.7, latMax: 83.1, lngMin: -141.0,lngMax: -52.6 }, { code: 'BR', latMin: -33.7, latMax: 5.3, lngMin: -73.9, lngMax: -34.8 }, ]; function latLngToCountryCode(lat: number, lng: number): string { // France métropolitaine en premier (cas le plus fréquent) for (const b of COUNTRY_BOXES) { if (lat >= b.latMin && lat <= b.latMax && lng >= b.lngMin && lng <= b.lngMax) return b.code; } return ''; } /** Nettoie le nom de ville Cesium+ : retire le code postal ("Paris, 75001" → "Paris") */ export function cleanCityName(city: string): string { return city.split(',')[0].trim(); } // Noms de pays en français/anglais → code ISO (Cesium+ utilise le français) const COUNTRY_NAME_TO_CODE: Record = { 'france': 'FR', 'belgique': 'BE', 'belgium': 'BE', 'suisse': 'CH', 'switzerland': 'CH', 'schweiz': 'CH', 'luxembourg': 'LU', 'allemagne': 'DE', 'germany': 'DE', 'espagne': 'ES', 'spain': 'ES', 'portugal': 'PT', 'italie': 'IT', 'italy': 'IT', 'pays-bas': 'NL', 'netherlands': 'NL', 'royaume-uni': 'GB', 'united kingdom': 'GB', 'maroc': 'MA', 'morocco': 'MA', 'tunisie': 'TN', 'tunisia': 'TN', 'sénégal': 'SN', 'senegal': 'SN', 'canada': 'CA', 'brésil': 'BR', 'brazil': 'BR', }; /** Extrait le pays depuis le champ city Cesium+ (ex: "Heusy, 4800, Belgique" → "BE") */ function countryCodeFromCity(city: string): string { const parts = city.split(','); for (let i = parts.length - 1; i >= 0; i--) { const token = parts[i].trim().toLowerCase(); const code = COUNTRY_NAME_TO_CODE[token]; if (code) return code; } return ''; } // 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().nullable().optional(), city: z.string().nullable().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(`${getCesiumUrl()}/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; const city = src.city ?? 'Inconnue'; result.set(hit._id, { name: src.title ?? '', city, countryCode: countryCodeFromCity(city) || latLngToCountryCode(geo.lat, geo.lng), lat: geo.lat, lng: geo.lng, }); } return result; } /** * Même que resolveGeoByKeys mais traite les grands tableaux par lots. * Nécessaire pour les 6000+ membres actifs (évite des requêtes ES trop grandes). */ export async function resolveGeoByKeysBatched( duniterKeys: string[], batchSize = 500, ): Promise> { const result = new Map(); for (let i = 0; i < duniterKeys.length; i += batchSize) { const batch = duniterKeys.slice(i, i + batchSize); const partial = await resolveGeoByKeys(batch); for (const [k, v] of partial) result.set(k, v); } 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(`${getCesiumUrl()}/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) { const city = src.city ?? 'Inconnue'; result.set(src.title.toLowerCase(), { name: src.title, city, countryCode: countryCodeFromCity(city) || latLngToCountryCode(geo.lat, geo.lng), lat: geo.lat, lng: geo.lng, }); } } return result; }