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>
165 lines
5.0 KiB
TypeScript
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;
|
|
}
|