Files
g1flux/src/services/adapters/CesiumAdapter.ts
syoul a288a5b2e7 fix: handle all ES geo_point formats from Cesium+
Cesium+ stores geoPoint in multiple Elasticsearch formats (object,
string "lat,lon", array [lon,lat]). Using z.object() caused a ZodError
that silently swallowed the entire Cesium+ response, leaving geoMap
empty and displaying 0 geolocalized transactions.

Replace the strict Zod schema with z.unknown() and a parseGeoPoint()
helper that normalizes all three formats. Also add [GéoFlux] debug
logs to DataService to trace keyMap/duniterKeys/geoMap pipeline steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:12:15 +01:00

165 lines
5.0 KiB
TypeScript

/**
* 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<string, unknown>;
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<Map<string, GeoProfile>> {
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<string, GeoProfile>();
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<name, GeoProfile>
*/
export async function resolveGeoByNames(
names: string[]
): Promise<Map<string, GeoProfile>> {
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<string, GeoProfile>();
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;
}