7 Commits

Author SHA1 Message Date
syoul 94474fc007 fix: détection pays fiable via nom de ville Cesium+ + reorder bounding boxes
- Extrait le pays depuis le champ city Cesium+ en priorité (ex: "Heusy, 4800, Belgique" → BE)
- Bounding boxes réordonnées : petits pays (LU, BE, CH, NL) avant FR pour éviter les faux positifs
- Affiche l'heure du dernier refresh sur le badge live

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:08:22 +01:00
syoul d99ad3707d fix: show full city name with postal code and country in top villes
Restore full Cesium+ city field (including postal code), restructure
the city card so name wraps on two lines with country badge + tx count
+ volume all readable without truncation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:55:37 +01:00
syoul 55d2b50cd3 fix: replace emoji flags with text badges, clean city names from postal codes
Emoji flags render as boxes on Linux. Replace with a small FR/BE/CH
badge. Also strip postal codes from Cesium+ city names (e.g.
"Saint-Jean-de-Laur, 46260" → "Saint-Jean-de-Laur").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:53:42 +01:00
syoul 8d9a9a3c07 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>
2026-03-22 18:52:00 +01:00
syoul a8792641ab feat: show up/down delta indicators on stats after each refresh
Volume total, transaction count, and top city volumes now display
↑ (green) or ↓ (red) arrows compared to the previous refresh,
making it visible that data is actually updating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:47:53 +01:00
syoul a6fc4a534f fix: use real totalCount from Subsquid instead of capped fetch limit
Add totalCount to the GraphQL query so transactionCount reflects the
true number of transfers in the period, not the 2000-item fetch cap.
This also fixes the average Ğ1/tx calculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:40:07 +01:00
syoul 2674b3891b fix: add error handling and refresh indicator to auto-refresh
- Add .catch() so failed background fetches don't silently break the interval
- Add refreshing state with a spinning ↻ on the live badge during background updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:34:47 +01:00
6 changed files with 174 additions and 54 deletions
+19 -9
View File
@@ -11,6 +11,8 @@ export default function App() {
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [stats, setStats] = useState<PeriodStats | null>(null); const [stats, setStats] = useState<PeriodStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [source, setSource] = useState<'live' | 'mock'>('mock'); const [source, setSource] = useState<'live' | 'mock'>('mock');
useEffect(() => { useEffect(() => {
@@ -18,14 +20,20 @@ export default function App() {
const load = (showLoading: boolean) => { const load = (showLoading: boolean) => {
if (showLoading) setLoading(true); if (showLoading) setLoading(true);
fetchData(periodDays).then(({ transactions, stats, source }) => { else setRefreshing(true);
if (!cancelled) { fetchData(periodDays)
setTransactions(transactions); .then(({ transactions, stats, source }) => {
setStats(stats); if (!cancelled) {
setSource(source); setTransactions(transactions);
setLoading(false); setStats(stats);
} setSource(source);
}); setLastUpdate(new Date());
}
})
.catch((err) => console.warn('Ğ1Flux refresh error:', err))
.finally(() => {
if (!cancelled) { setLoading(false); setRefreshing(false); }
});
}; };
load(true); load(true);
@@ -59,7 +67,9 @@ export default function App() {
? 'bg-emerald-950/80 border-emerald-700 text-emerald-400' ? 'bg-emerald-950/80 border-emerald-700 text-emerald-400'
: 'bg-[#0a0b0f]/80 border-[#2e2f3a] text-[#4b5563]' : 'bg-[#0a0b0f]/80 border-[#2e2f3a] text-[#4b5563]'
}`}> }`}>
{source === 'live' ? '● live Ğ1v2' : '○ mock'} {source === 'live'
? <>{refreshing ? <span className="animate-spin inline-block"></span> : '●'} live Ğ1v2{lastUpdate && <span className="ml-1 opacity-60">{lastUpdate.toLocaleTimeString('fr-FR')}</span>}</>
: '○ mock'}
</div> </div>
</div> </div>
)} )}
+46 -9
View File
@@ -1,3 +1,4 @@
import { useRef } from 'react';
import type { PeriodStats } from '../services/DataService'; import type { PeriodStats } from '../services/DataService';
interface StatsPanelProps { interface StatsPanelProps {
@@ -9,11 +10,15 @@ interface StatsPanelProps {
const MEDALS = ['🥇', '🥈', '🥉']; const MEDALS = ['🥇', '🥈', '🥉'];
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) { function StatCard({ label, value, sub, delta }: { label: string; value: string; sub?: string; delta?: 'up' | 'down' | null }) {
return ( return (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 space-y-1"> <div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 space-y-1">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">{label}</p> <p className="text-[#4b5563] text-xs uppercase tracking-widest">{label}</p>
<p className="text-[#d4a843] text-2xl font-bold tabular-nums">{value}</p> <p className="text-[#d4a843] text-2xl font-bold tabular-nums">
{value}
{delta === 'up' && <span className="text-emerald-400 text-sm ml-1.5"></span>}
{delta === 'down' && <span className="text-red-400 text-sm ml-1.5"></span>}
</p>
{sub && <p className="text-[#6b7280] text-xs">{sub}</p>} {sub && <p className="text-[#6b7280] text-xs">{sub}</p>}
</div> </div>
); );
@@ -21,6 +26,26 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) { export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
// Calcule le delta d'une valeur par rapport au refresh précédent
function delta(current: number, prevMap: Map<string, number>, key: string) {
const prev = prevMap.get(key);
if (prev === undefined) return null;
if (current > prev) return <span className="text-emerald-400 text-xs ml-1"></span>;
if (current < prev) return <span className="text-red-400 text-xs ml-1"></span>;
return null;
}
// Construit une map volume précédent par ville
const prevCityVolume = new Map(
(prevStats.current?.topCities ?? []).map((c) => [c.name, c.volume])
);
const prevVolume = prevStats.current?.totalVolume ?? null;
const prevTxCount = prevStats.current?.transactionCount ?? null;
// Mémorise les stats après le rendu
if (stats && !loading) prevStats.current = stats;
const geoPct = stats && stats.transactionCount > 0 const geoPct = stats && stats.transactionCount > 0
? Math.round((stats.geoCount / stats.transactionCount) * 100) ? Math.round((stats.geoCount / stats.transactionCount) * 100)
: null; : null;
@@ -55,11 +80,13 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
<StatCard <StatCard
label="Volume total" label="Volume total"
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`} value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
/> />
<StatCard <StatCard
label="Transactions" label="Transactions"
value={stats.transactionCount.toLocaleString('fr-FR')} value={stats.transactionCount.toLocaleString('fr-FR')}
sub={`${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`} sub={`${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`}
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
/> />
{/* Couverture géo — uniquement en mode live */} {/* Couverture géo — uniquement en mode live */}
{source === 'live' && geoPct !== null && ( {source === 'live' && geoPct !== null && (
@@ -89,16 +116,26 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
{stats.topCities.map((city, i) => ( {stats.topCities.map((city, i) => (
<div <div
key={city.name} key={city.name}
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex items-center gap-3" className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex gap-2.5"
> >
<span className="text-base">{MEDALS[i]}</span> <span className="text-base shrink-0 mt-0.5">{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-xs font-medium leading-snug">{city.name}</p>
<p className="text-[#6b7280] text-xs">{city.count} tx</p> <div className="flex items-center justify-between mt-1">
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
{city.countryCode && (
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
{city.countryCode}
</span>
)}
{city.count} tx
</span>
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
{delta(city.volume, prevCityVolume, city.name)}
</span>
</div>
</div> </div>
<span className="text-[#d4a843] text-sm font-mono shrink-0">
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
</span>
</div> </div>
))} ))}
</div> </div>
+4 -2
View File
@@ -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;
+13 -13
View File
@@ -37,10 +37,9 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
totalCount: number; totalCount: number;
totalVolume: number; totalVolume: number;
}> { }> {
const rawTransfers = await fetchTransfers(periodDays); const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays);
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 }; if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 };
const totalCount = rawTransfers.length;
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0); const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
// Carte SS58 courant → clé Duniter (= _id Cesium+) // Carte SS58 courant → clé Duniter (= _id Cesium+)
@@ -57,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);
@@ -76,14 +75,15 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
if (!geo) continue; if (!geo) continue;
geolocated.push({ geolocated.push({
id: t.id, id: t.id,
timestamp: t.timestamp, timestamp: t.timestamp,
lat: geo.lat, lat: geo.lat,
lng: geo.lng, lng: geo.lng,
amount: t.amount, amount: t.amount,
city: geo.city, city: geo.city,
fromKey: t.fromId, countryCode: geo.countryCode,
toKey: t.toId, fromKey: t.fromId,
toKey: t.toId,
}); });
} }
@@ -97,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 {
+73 -12
View File
@@ -16,10 +16,67 @@ import { z } from 'zod';
export const CESIUM_ENDPOINT = 'https://g1.data.e-is.pro'; 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;
lat: number; countryCode: string; // ISO 3166-1 alpha-2, ex: "FR"
lng: number; 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 // geoPoint accepte n'importe quel type — Cesium+ utilise plusieurs formats ES geo_point
@@ -94,11 +151,13 @@ export async function resolveGeoByKeys(
const src = hit._source; const src = hit._source;
const geo = parseGeoPoint(src.geoPoint); const geo = parseGeoPoint(src.geoPoint);
if (!geo) continue; if (!geo) continue;
const city = src.city ?? 'Inconnue';
result.set(hit._id, { result.set(hit._id, {
name: src.title ?? '', name: src.title ?? '',
city: src.city ?? 'Inconnue', city,
lat: geo.lat, countryCode: countryCodeFromCity(city) || latLngToCountryCode(geo.lat, geo.lng),
lng: geo.lng, lat: geo.lat,
lng: geo.lng,
}); });
} }
return result; return result;
@@ -152,11 +211,13 @@ export async function resolveGeoByNames(
const src = hit._source; const src = hit._source;
const geo = parseGeoPoint(src.geoPoint); const geo = parseGeoPoint(src.geoPoint);
if (geo && src.title) { if (geo && src.title) {
const city = src.city ?? 'Inconnue';
result.set(src.title.toLowerCase(), { result.set(src.title.toLowerCase(), {
name: src.title, name: src.title,
city: src.city ?? 'Inconnue', city,
lat: geo.lat, countryCode: countryCodeFromCity(city) || latLngToCountryCode(geo.lat, geo.lng),
lng: geo.lng, lat: geo.lat,
lng: geo.lng,
}); });
} }
} }
+19 -9
View File
@@ -33,6 +33,7 @@ const SubsquidTransferNodeSchema = z.object({
const SubsquidResponseSchema = z.object({ const SubsquidResponseSchema = z.object({
data: z.object({ data: z.object({
transfers: z.object({ transfers: z.object({
totalCount: z.number().int(),
nodes: z.array(SubsquidTransferNodeSchema), nodes: z.array(SubsquidTransferNodeSchema),
}), }),
}), }),
@@ -62,6 +63,7 @@ const TRANSFERS_QUERY = `
first: $limit first: $limit
filter: { timestamp: { greaterThanOrEqualTo: $since } } filter: { timestamp: { greaterThanOrEqualTo: $since } }
) { ) {
totalCount
nodes { nodes {
id id
blockNumber blockNumber
@@ -151,10 +153,15 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
return result; return result;
} }
export interface FetchTransfersResult {
transfers: RawTransfer[];
totalCount: number;
}
export async function fetchTransfers( export async function fetchTransfers(
periodDays: number, periodDays: number,
limit = 2000 limit = 2000
): Promise<RawTransfer[]> { ): Promise<FetchTransfersResult> {
const since = new Date( const since = new Date(
Date.now() - periodDays * 24 * 60 * 60 * 1000 Date.now() - periodDays * 24 * 60 * 60 * 1000
).toISOString(); ).toISOString();
@@ -180,12 +187,15 @@ export async function fetchTransfers(
const parsed = SubsquidResponseSchema.parse(raw); const parsed = SubsquidResponseSchema.parse(raw);
return parsed.data.transfers.nodes.map((node) => ({ return {
id: node.id, totalCount: parsed.data.transfers.totalCount,
timestamp: new Date(node.timestamp).getTime(), transfers: parsed.data.transfers.nodes.map((node) => ({
amount: parseInt(node.amount, 10) / 100, id: node.id,
fromId: node.fromId ?? '', timestamp: new Date(node.timestamp).getTime(),
toId: node.toId ?? '', amount: parseInt(node.amount, 10) / 100,
fromName: node.from?.linkedIdentity?.name ?? '', fromId: node.fromId ?? '',
})); toId: node.toId ?? '',
fromName: node.from?.linkedIdentity?.name ?? '',
})),
};
} }