Files
g1flux/src/services/adapters/CesiumAdapter.ts
T
syoul 0d9415ae6a
ci/woodpecker/push/woodpecker Pipeline was successful
feat: indicateurs de statut et configuration des endpoints SubSquid/Cesium+
- Dots de statut en temps réel dans le StatsPanel (ok/slow/error + latence)
- Bannière d'alerte si un service est inaccessible
- EndpointPopover : sélection parmi nœuds connus, test de latence live, URL custom
- Rechargement automatique des données après changement d'endpoint
- SubsquidAdapter et CesiumAdapter lisent l'URL active depuis EndpointConfig
- InfoPanel mis à jour (overlay DU + statut des services)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:43:33 +02:00

244 lines
8.8 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';
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<string, string> = {
'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<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(`${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<string, GeoProfile>();
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<Map<string, GeoProfile>> {
const result = new Map<string, GeoProfile>();
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<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(`${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<string, GeoProfile>();
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;
}