feat: show country flag next to city names in top villes
Determine country from geoPoint coordinates using bounding boxes for the main Ğ1 community countries (FR, BE, CH, ES, DE, IT, ...). Display the emoji flag before each city name in the top villes panel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import type { PeriodStats } from '../services/DataService';
|
import type { PeriodStats } from '../services/DataService';
|
||||||
|
import { countryCodeToFlag } from '../services/adapters/CesiumAdapter';
|
||||||
|
|
||||||
interface StatsPanelProps {
|
interface StatsPanelProps {
|
||||||
stats: PeriodStats | null;
|
stats: PeriodStats | null;
|
||||||
@@ -120,7 +121,9 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
|||||||
>
|
>
|
||||||
<span className="text-base">{MEDALS[i]}</span>
|
<span className="text-base">{MEDALS[i]}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white text-sm font-medium truncate">{city.name}</p>
|
<p className="text-white text-sm font-medium truncate">
|
||||||
|
{countryCodeToFlag(city.countryCode)}{city.countryCode ? ' ' : ''}{city.name}
|
||||||
|
</p>
|
||||||
<p className="text-[#6b7280] text-xs">{city.count} tx</p>
|
<p className="text-[#6b7280] text-xs">{city.count} tx</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[#d4a843] text-sm font-mono shrink-0 flex items-center gap-1">
|
<span className="text-[#d4a843] text-sm font-mono shrink-0 flex items-center gap-1">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Transaction {
|
|||||||
lng: number;
|
lng: number;
|
||||||
amount: number; // Ğ1 (pas en centimes)
|
amount: number; // Ğ1 (pas en centimes)
|
||||||
city: string;
|
city: string;
|
||||||
|
countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
|
||||||
fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars
|
fromKey: string; // SS58 Ğ1v2 : préfixe "g1", ~50 chars
|
||||||
toKey: string;
|
toKey: string;
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,7 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
|||||||
lng,
|
lng,
|
||||||
amount,
|
amount,
|
||||||
city: city.name,
|
city: city.name,
|
||||||
|
countryCode: 'FR',
|
||||||
fromKey: generateKey(),
|
fromKey: generateKey(),
|
||||||
toKey: generateKey(),
|
toKey: generateKey(),
|
||||||
});
|
});
|
||||||
@@ -94,10 +96,10 @@ export function computeStats(transactions: Transaction[]) {
|
|||||||
const totalVolume = transactions.reduce((sum, tx) => sum + tx.amount, 0);
|
const totalVolume = transactions.reduce((sum, tx) => sum + tx.amount, 0);
|
||||||
const transactionCount = transactions.length;
|
const transactionCount = transactions.length;
|
||||||
|
|
||||||
const cityVolumes: Record<string, { volume: number; count: number }> = {};
|
const cityVolumes: Record<string, { volume: number; count: number; countryCode: string }> = {};
|
||||||
for (const tx of transactions) {
|
for (const tx of transactions) {
|
||||||
if (!cityVolumes[tx.city]) {
|
if (!cityVolumes[tx.city]) {
|
||||||
cityVolumes[tx.city] = { volume: 0, count: 0 };
|
cityVolumes[tx.city] = { volume: 0, count: 0, countryCode: tx.countryCode ?? '' };
|
||||||
}
|
}
|
||||||
cityVolumes[tx.city].volume += tx.amount;
|
cityVolumes[tx.city].volume += tx.amount;
|
||||||
cityVolumes[tx.city].count += 1;
|
cityVolumes[tx.city].count += 1;
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
|||||||
)];
|
)];
|
||||||
|
|
||||||
// Résolution géo par clé Duniter (_id Cesium+)
|
// Résolution géo par clé Duniter (_id Cesium+)
|
||||||
let geoMap = new Map<string, { lat: number; lng: number; city: string }>();
|
let geoMap = new Map<string, { lat: number; lng: number; city: string; countryCode: string }>();
|
||||||
try {
|
try {
|
||||||
const profiles = await resolveGeoByKeys(duniterKeys);
|
const profiles = await resolveGeoByKeys(duniterKeys);
|
||||||
for (const [key, p] of profiles) {
|
for (const [key, p] of profiles) {
|
||||||
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city });
|
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Cesium+ indisponible :', err);
|
console.warn('Cesium+ indisponible :', err);
|
||||||
@@ -81,6 +81,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
|||||||
lng: geo.lng,
|
lng: geo.lng,
|
||||||
amount: t.amount,
|
amount: t.amount,
|
||||||
city: geo.city,
|
city: geo.city,
|
||||||
|
countryCode: geo.countryCode,
|
||||||
fromKey: t.fromId,
|
fromKey: t.fromId,
|
||||||
toKey: t.toId,
|
toKey: t.toId,
|
||||||
});
|
});
|
||||||
@@ -96,7 +97,7 @@ export interface PeriodStats {
|
|||||||
totalVolume: number;
|
totalVolume: number;
|
||||||
transactionCount: number; // total blockchain (y compris non-géolocalisés)
|
transactionCount: number; // total blockchain (y compris non-géolocalisés)
|
||||||
geoCount: number; // transactions visibles sur la carte
|
geoCount: number; // transactions visibles sur la carte
|
||||||
topCities: { name: string; volume: number; count: number }[];
|
topCities: { name: string; volume: number; count: number; countryCode: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataResult {
|
export interface DataResult {
|
||||||
|
|||||||
@@ -18,10 +18,46 @@ export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro';
|
|||||||
export interface GeoProfile {
|
export interface GeoProfile {
|
||||||
name: string; // nom d'identité Ğ1 (ex: "Anikka")
|
name: string; // nom d'identité Ğ1 (ex: "Anikka")
|
||||||
city: string;
|
city: string;
|
||||||
|
countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: 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 }[] = [
|
||||||
|
{ code: 'FR', latMin: 41.3, latMax: 51.1, lngMin: -5.1, lngMax: 9.6 },
|
||||||
|
{ 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: 'LU', latMin: 49.4, latMax: 50.2, lngMin: 5.7, lngMax: 6.5 },
|
||||||
|
{ code: 'DE', latMin: 47.3, latMax: 55.1, lngMin: 6.0, lngMax: 15.0 },
|
||||||
|
{ 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: 'NL', latMin: 50.7, latMax: 53.6, lngMin: 3.3, lngMax: 7.2 },
|
||||||
|
{ 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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convertit un code ISO 3166-1 alpha-2 en emoji drapeau */
|
||||||
|
export function countryCodeToFlag(code: string): string {
|
||||||
|
if (!code || code.length !== 2) return '';
|
||||||
|
return String.fromCodePoint(
|
||||||
|
...code.toUpperCase().split('').map((c) => 0x1F1E0 - 65 + c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// geoPoint accepte n'importe quel type — Cesium+ utilise plusieurs formats ES geo_point
|
// geoPoint accepte n'importe quel type — Cesium+ utilise plusieurs formats ES geo_point
|
||||||
const HitSchema = z.object({
|
const HitSchema = z.object({
|
||||||
_id: z.string(),
|
_id: z.string(),
|
||||||
@@ -97,6 +133,7 @@ export async function resolveGeoByKeys(
|
|||||||
result.set(hit._id, {
|
result.set(hit._id, {
|
||||||
name: src.title ?? '',
|
name: src.title ?? '',
|
||||||
city: src.city ?? 'Inconnue',
|
city: src.city ?? 'Inconnue',
|
||||||
|
countryCode: latLngToCountryCode(geo.lat, geo.lng),
|
||||||
lat: geo.lat,
|
lat: geo.lat,
|
||||||
lng: geo.lng,
|
lng: geo.lng,
|
||||||
});
|
});
|
||||||
@@ -155,6 +192,7 @@ export async function resolveGeoByNames(
|
|||||||
result.set(src.title.toLowerCase(), {
|
result.set(src.title.toLowerCase(), {
|
||||||
name: src.title,
|
name: src.title,
|
||||||
city: src.city ?? 'Inconnue',
|
city: src.city ?? 'Inconnue',
|
||||||
|
countryCode: latLngToCountryCode(geo.lat, geo.lng),
|
||||||
lat: geo.lat,
|
lat: geo.lat,
|
||||||
lng: geo.lng,
|
lng: geo.lng,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user