diff --git a/src/App.tsx b/src/App.tsx
index c7239ed..52c1d77 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -32,7 +32,7 @@ export default function App() {
return (
{/* Side panel */}
-
+
{/* Map area */}
diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx
index a75e622..b5d35c7 100644
--- a/src/components/StatsPanel.tsx
+++ b/src/components/StatsPanel.tsx
@@ -4,6 +4,7 @@ interface StatsPanelProps {
stats: PeriodStats | null;
loading: boolean;
periodDays: number;
+ source: 'live' | 'mock';
}
const MEDALS = ['đ„', 'đ„', 'đ„'];
@@ -18,8 +19,11 @@ function StatCard({ label, value, sub }: { label: string; value: string; sub?: s
);
}
-export function StatsPanel({ stats, loading, periodDays }: StatsPanelProps) {
+export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 derniĂšres heures' : `${periodDays} derniers jours`;
+ const geoPct = stats && stats.transactionCount > 0
+ ? Math.round((stats.geoCount / stats.transactionCount) * 100)
+ : null;
return (
) : null}
@@ -87,7 +107,7 @@ export function StatsPanel({ stats, loading, periodDays }: StatsPanelProps) {
{/* Footer */}
- DonnĂ©es simulĂ©es · API Subsquid prĂȘte
+ {source === 'live' ? 'Ä1v2 · Subsquid + Cesium+' : 'DonnĂ©es simulĂ©es · mock'}
diff --git a/src/schemas/g1.schema.ts b/src/schemas/g1.schema.ts
index 0d19529..755ba4e 100644
--- a/src/schemas/g1.schema.ts
+++ b/src/schemas/g1.schema.ts
@@ -22,6 +22,28 @@ export const G1v2KeySchema = z.string()
.startsWith('g1')
.length(49);
+// ---------------------------------------------------------------------------
+// Ä1v1 (Duniter GVA) â conservĂ© pour rĂ©fĂ©rence, non utilisĂ© en production v2
+// ---------------------------------------------------------------------------
+export const GvaTransactionNodeSchema = z.object({
+ currency: z.literal('g1'),
+ issuers: z.array(z.string().min(43).max(44)),
+ outputs: z.array(z.string().regex(/^\d+:\d+:SIG\(.+\)$/)).min(1),
+ blockstampTime: z.number().int().positive(),
+ comment: z.string().optional(),
+ hash: z.string().optional(),
+});
+
+export const GvaResponseSchema = z.object({
+ data: z.object({
+ txsHistoryBc: z.object({
+ both: z.object({
+ edges: z.array(z.object({ node: GvaTransactionNodeSchema })),
+ }),
+ }),
+ }),
+});
+
// ---------------------------------------------------------------------------
// Subsquid â rĂ©ponse brute d'un transfert Ä1v2
// Endpoint : POST https://squidv2s.syoul.fr/v1/graphql
@@ -59,8 +81,9 @@ export const CesiumProfileSchema = z.object({
title: z.string().optional(),
city: z.string().optional(),
geoPoint: z.object({
- lat: z.number().min(-90).max(90),
- lon: z.number().min(-180).max(180),
+ // Certains profils Cesium+ ont lat/lon en string â coerce pour les deux cas
+ lat: z.coerce.number().min(-90).max(90),
+ lon: z.coerce.number().min(-180).max(180),
}).optional(),
}),
});
diff --git a/src/services/DataService.ts b/src/services/DataService.ts
index 6184538..aad0a4b 100644
--- a/src/services/DataService.ts
+++ b/src/services/DataService.ts
@@ -5,8 +5,9 @@
* Mode live (USE_LIVE_API = true) : donnĂ©es rĂ©elles Ä1v2.
* - Transactions : Subsquid indexer https://squidv2s.syoul.fr/v1/graphql
* - Géolocalisation : Cesium+ https://g1.data.e-is.pro
- * â recherche par nom d'identitĂ© (identity.name depuis le graphe Subsquid)
- * â les transactions sans profil Cesium+ reçoivent des coordonnĂ©es approx.
+ * â recherche batch par nom d'identitĂ© (champ "title" analysĂ© ES)
+ * â couverture ~50-60% : les tx sans profil gĂ©o sont EXCLUES du heatmap
+ * mais comptées dans totalCount / totalVolume
*
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/
@@ -19,27 +20,21 @@ import {
type Transaction,
} from '../data/mockData';
-// ---------------------------------------------------------------------------
-// Configuration
-// ---------------------------------------------------------------------------
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
-// CentroĂŻde France â fallback gĂ©o quand Cesium+ n'a pas le profil
-const FRANCE_CENTER = { lat: 46.2276, lng: 2.2137 };
-
-// ---------------------------------------------------------------------------
-// Pipeline donnĂ©es live Ä1v2
-// ---------------------------------------------------------------------------
-async function fetchLiveTransactions(periodDays: number): Promise
{
- // 1. RécupÚre les transferts depuis Subsquid
+async function fetchLiveTransactions(periodDays: number): Promise<{
+ geolocated: Transaction[];
+ totalCount: number;
+ totalVolume: number;
+}> {
const rawTransfers = await fetchTransfers(periodDays);
+ if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 };
- if (rawTransfers.length === 0) return [];
+ const totalCount = rawTransfers.length;
+ const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
- // 2. Collecte les noms d'identité uniques pour la recherche Cesium+
+ // Résolution géo batch via Cesium+
const names = rawTransfers.map((t) => t.fromName).filter(Boolean);
-
- // 3. RĂ©sout les coordonnĂ©es via Cesium+ (une seule requĂȘte batch)
let geoMap = new Map();
try {
const profiles = await resolveGeoByNames(names);
@@ -47,53 +42,69 @@ async function fetchLiveTransactions(periodDays: number): Promise
geoMap.set(name, { lat: p.lat, lng: p.lng, city: p.city });
}
} catch (err) {
- console.warn('Cesium+ indisponible, fallback coordonnées France :', err);
+ console.warn('Cesium+ indisponible :', err);
}
- // 4. Assemble les transactions avec coordonnées
- return rawTransfers.map((t): Transaction => {
- const geo = geoMap.get(t.fromName) ?? FRANCE_CENTER;
- return {
+ // Seules les transactions avec un profil géo entrent dans le heatmap
+ const geolocated: Transaction[] = [];
+ for (const t of rawTransfers) {
+ const geo = geoMap.get(t.fromName);
+ if (!geo) continue; // pas de profil â exclu du heatmap
+
+ geolocated.push({
id: t.id,
timestamp: t.timestamp,
lat: geo.lat,
lng: geo.lng,
amount: t.amount,
- city: ('city' in geo ? geo.city : undefined) ?? 'Inconnue',
+ city: geo.city,
fromKey: t.fromId,
toKey: t.toId,
- };
- });
+ });
+ }
+
+ return { geolocated, totalCount, totalVolume };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface PeriodStats {
- totalVolume: number;
- transactionCount: number;
+ totalVolume: number;
+ transactionCount: number; // total blockchain (y compris non-géolocalisés)
+ geoCount: number; // transactions visibles sur la carte
topCities: { name: string; volume: number; count: number }[];
}
export interface DataResult {
- transactions: Transaction[];
- stats: PeriodStats;
+ transactions: Transaction[]; // uniquement gĂ©olocalisĂ©es â heatmap
+ stats: PeriodStats;
source: 'live' | 'mock';
}
export async function fetchData(periodDays: number): Promise {
- let transactions: Transaction[];
- let source: 'live' | 'mock';
-
- if (USE_LIVE_API) {
- transactions = await fetchLiveTransactions(periodDays);
- source = 'live';
- } else {
+ if (!USE_LIVE_API) {
await new Promise((r) => setTimeout(r, 80));
- transactions = getTransactionsForPeriod(periodDays);
- source = 'mock';
+ const transactions = getTransactionsForPeriod(periodDays);
+ const base = computeStats(transactions);
+ return {
+ transactions,
+ stats: { ...base, geoCount: transactions.length },
+ source: 'mock',
+ };
}
- const stats = computeStats(transactions);
- return { transactions, stats, source };
+ const { geolocated, totalCount, totalVolume } = await fetchLiveTransactions(periodDays);
+ const base = computeStats(geolocated);
+
+ return {
+ transactions: geolocated,
+ stats: {
+ totalVolume, // vrai total blockchain
+ transactionCount: totalCount,
+ geoCount: geolocated.length,
+ topCities: base.topCities,
+ },
+ source: 'live',
+ };
}
diff --git a/src/services/adapters/CesiumAdapter.ts b/src/services/adapters/CesiumAdapter.ts
index 1b26854..28d6986 100644
--- a/src/services/adapters/CesiumAdapter.ts
+++ b/src/services/adapters/CesiumAdapter.ts
@@ -53,11 +53,13 @@ export async function resolveGeoByNames(
if (unique.length === 0) return new Map();
const query = {
- size: unique.length,
+ // DĂ©passer la limite par dĂ©faut : plusieurs profils peuvent avoir le mĂȘme prĂ©nom
+ size: unique.length * 3,
query: {
bool: {
must: [
- { terms: { 'title.keyword': unique } },
+ // Champ "title" analysĂ© (lowercase tokens) â title.keyword retourne 0 rĂ©sultats
+ { terms: { title: unique } },
],
filter: [
{ exists: { field: 'geoPoint' } },
diff --git a/src/test/DataService.test.ts b/src/test/DataService.test.ts
index 8fd3dab..1e7e66a 100644
--- a/src/test/DataService.test.ts
+++ b/src/test/DataService.test.ts
@@ -1,44 +1,69 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { fetchData } from '../services/DataService';
-// Mock the mockData module so tests are deterministic
+// Mocker les adaptateurs live AVANT l'import de DataService,
+// pour que les tests soient déterministes quel que soit VITE_USE_LIVE_API
+vi.mock('../services/adapters/SubsquidAdapter', () => ({
+ fetchTransfers: vi.fn(async (days: number) => [
+ { id: 't1', timestamp: Date.now(), amount: 20, fromId: 'g1' + 'a'.repeat(47), toId: 'g1' + 'b'.repeat(47), fromName: 'Alice' },
+ { id: 't2', timestamp: Date.now(), amount: 10, fromId: 'g1' + 'c'.repeat(47), toId: 'g1' + 'd'.repeat(47), fromName: 'Bob' },
+ ].slice(0, days >= 7 ? 2 : 1)),
+}));
+
+vi.mock('../services/adapters/CesiumAdapter', () => ({
+ resolveGeoByNames: vi.fn(async () => new Map([
+ ['Alice', { name: 'Alice', city: 'Paris', lat: 48.8566, lng: 2.3522 }],
+ ['Bob', { name: 'Bob', city: 'Lyon', lat: 45.764, lng: 4.8357 }],
+ ])),
+}));
+
vi.mock('../data/mockData', () => ({
getTransactionsForPeriod: vi.fn((days: number) => [
- { id: 't1', timestamp: Date.now(), lat: 48.8, lng: 2.3, amount: 20, city: 'Paris', fromKey: 'a', toKey: 'b' },
- { id: 't2', timestamp: Date.now(), lat: 45.7, lng: 4.8, amount: 10, city: 'Lyon', fromKey: 'c', toKey: 'd' },
+ { id: 't1', timestamp: Date.now(), lat: 48.8, lng: 2.3, amount: 20, city: 'Paris', fromKey: 'g1' + 'a'.repeat(47), toKey: 'g1' + 'b'.repeat(47) },
+ { id: 't2', timestamp: Date.now(), lat: 45.7, lng: 4.8, amount: 10, city: 'Lyon', fromKey: 'g1' + 'c'.repeat(47), toKey: 'g1' + 'd'.repeat(47) },
].slice(0, days >= 7 ? 2 : 1)),
computeStats: vi.fn((txs) => ({
- totalVolume: txs.reduce((s: number, t: { amount: number }) => s + t.amount, 0),
+ totalVolume: txs.reduce((s: number, t: { amount: number }) => s + t.amount, 0),
transactionCount: txs.length,
- topCities: [{ name: 'Paris', volume: 20, count: 1 }],
+ topCities: [{ name: 'Paris', volume: 20, count: 1 }],
})),
}));
beforeEach(() => vi.clearAllMocks());
+import { fetchData } from '../services/DataService';
+
describe('fetchData', () => {
- it('returns transactions and stats shaped correctly', async () => {
+ it('retourne transactions et stats avec la bonne forme', async () => {
const result = await fetchData(7);
expect(result).toHaveProperty('transactions');
expect(result).toHaveProperty('stats');
+ expect(result).toHaveProperty('source');
expect(result.stats).toHaveProperty('totalVolume');
expect(result.stats).toHaveProperty('transactionCount');
+ expect(result.stats).toHaveProperty('geoCount');
expect(result.stats).toHaveProperty('topCities');
});
- it('filters by period â 1 day returns fewer transactions than 7 days', async () => {
+ it('1 jour retourne moins de transactions que 7 jours', async () => {
const r1 = await fetchData(1);
const r7 = await fetchData(7);
expect(r7.transactions.length).toBeGreaterThanOrEqual(r1.transactions.length);
});
- it('stats.totalVolume matches sum of returned transactions', async () => {
+ it('totalVolume >= somme des transactions géolocalisées', async () => {
const { transactions, stats } = await fetchData(7);
- const sum = transactions.reduce((s, t) => s + t.amount, 0);
- expect(stats.totalVolume).toBeCloseTo(sum);
+ const geoSum = transactions.reduce((s, t) => s + t.amount, 0);
+ // En mode live : totalVolume = total blockchain >= geolocalisées
+ // En mode mock : totalVolume = geoSum (mĂȘme ensemble)
+ expect(stats.totalVolume).toBeGreaterThanOrEqual(geoSum - 0.01);
});
- it('resolves (does not reject) for all valid periods', async () => {
+ it('geoCount <= transactionCount', async () => {
+ const { stats } = await fetchData(7);
+ expect(stats.geoCount).toBeLessThanOrEqual(stats.transactionCount);
+ });
+
+ it('ne rejette pas pour les trois périodes valides', async () => {
await expect(fetchData(1)).resolves.toBeDefined();
await expect(fetchData(7)).resolves.toBeDefined();
await expect(fetchData(30)).resolves.toBeDefined();
diff --git a/tsconfig.app.json b/tsconfig.app.json
index af516fc..bd518c4 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -24,5 +24,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["src"]
+ "include": ["src"],
+ "exclude": ["src/test"]
}