From 575ca7a1fc5b31198ccce131984de398f341f98d Mon Sep 17 00:00:00 2001 From: syoul Date: Sat, 28 Mar 2026 12:28:58 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20raccourcis=20clavier,=20URL=20parta?= =?UTF-8?q?geable,=20sparkline,=20recherche=20identit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raccourcis clavier : ←/→ (frames), Espace (play/pause), Échap (quitter animation/fermer info), H (basculer heatmap↔flux) - URL partageable : ?period=7&view=flow&city=Paris — état restauré au chargement et mis à jour sans rechargement (history.replaceState) - Sparkline : mini bar-chart SVG dans le StatsPanel montrant l'activité sur la période (données déjà en mémoire, aucune requête) - Recherche identité : champ flottant (⌕) acceptant un nom Ğ1 ou une clé g1…, résout via Subsquid + Cesium+, bascule en vue flux et met la ville en focus Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 47 ++++++++++- src/components/SearchBar.tsx | 151 ++++++++++++++++++++++++++++++++++ src/components/Sparkline.tsx | 66 +++++++++++++++ src/components/StatsPanel.tsx | 23 ++++-- src/hooks/useUrlState.ts | 39 +++++++++ 5 files changed, 315 insertions(+), 11 deletions(-) create mode 100644 src/components/SearchBar.tsx create mode 100644 src/components/Sparkline.tsx create mode 100644 src/hooks/useUrlState.ts diff --git a/src/App.tsx b/src/App.tsx index b92e8a2..89c6776 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { PeriodSelector } from './components/PeriodSelector'; import { HeatMap } from './components/HeatMap'; import { FlowMap } from './components/FlowMap'; import { AnimationPlayer } from './components/AnimationPlayer'; +import { SearchBar } from './components/SearchBar'; import { fetchData } from './services/DataService'; import type { PeriodStats } from './services/DataService'; import type { Transaction } from './data/mockData'; @@ -13,9 +14,10 @@ import { computeFlowStats } from './data/arcData'; import { useAnimation } from './hooks/useAnimation'; import { useMediaQuery } from './hooks/useMediaQuery'; import { InfoPanel } from './components/InfoPanel'; +import { initialUrlState, useUrlSync } from './hooks/useUrlState'; export default function App() { - const [periodDays, setPeriodDays] = useState(7); + const [periodDays, setPeriodDays] = useState(initialUrlState.period); const [transactions, setTransactions] = useState([]); const [arcs, setArcs] = useState([]); const [stats, setStats] = useState(null); @@ -25,14 +27,17 @@ export default function App() { const [source, setSource] = useState<'live' | 'mock'>('mock'); const [currentUD, setCurrentUD] = useState(11.78); const [allTimestamps, setAllTimestamps] = useState([]); - const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap'); - const [focusCity, setFocusCity] = useState(null); + const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>(initialUrlState.view); + const [focusCity, setFocusCity] = useState(initialUrlState.city); const [panelOpen, setPanelOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false); const isMobile = useMediaQuery('(max-width: 639px)'); const animation = useAnimation(transactions, arcs, periodDays, allTimestamps); + // Synchronise l'état dans l'URL (deep link / partage) + useUrlSync(periodDays, viewMode, focusCity); + const handlePeriodChange = (days: number) => { animation.deactivate(); setPeriodDays(days); @@ -43,6 +48,31 @@ export default function App() { setFocusCity(null); }; + // Raccourcis clavier + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (e.key === 'ArrowLeft' && animation.active) { + animation.seek(Math.max(0, animation.currentIndex - 1)); + e.preventDefault(); + } else if (e.key === 'ArrowRight' && animation.active) { + animation.seek(Math.min(animation.frames.length - 1, animation.currentIndex + 1)); + e.preventDefault(); + } else if (e.key === ' ' && animation.active) { + animation.playing ? animation.pause() : animation.play(); + e.preventDefault(); + } else if (e.key === 'Escape') { + if (infoOpen) { setInfoOpen(false); e.preventDefault(); } + else if (animation.active) { animation.deactivate(); e.preventDefault(); } + } else if (e.key === 'h' || e.key === 'H') { + handleViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap'); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animation.active, animation.playing, animation.currentIndex, animation.frames.length, infoOpen, viewMode]); + useEffect(() => { let cancelled = false; @@ -101,6 +131,7 @@ export default function App() { viewMode, flowStats, focusCity, + allTimestamps, }; return ( @@ -140,6 +171,16 @@ export default function App() { ℹ + {/* Barre de recherche identité */} +
+ { + setViewMode('flow'); + setFocusCity(city); + }} + /> +
+ {/* Period selector — floating over map */}
void; +} + +async function resolveQuery(query: string): Promise<{ name: string; city: string } | null> { + const q = query.trim(); + if (!q) return null; + + // Clé SS58 Ğ1v2 : commence par "g1" et fait ~50 caractères + const isKey = /^g1[1-9A-HJ-NP-Za-km-z]{40,}$/.test(q); + + let duniterKey: string; + let identityName: string; + + if (isKey) { + duniterKey = ss58ToDuniterKey(q); + identityName = q.slice(0, 10) + '…'; + } else { + const res = await fetch(SUBSQUID_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query($q: String!) { + identities(filter: { name: { includesInsensitive: $q } }, first: 1) { + nodes { + accountId + name + ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) { + nodes { previousId } + } + } + } + } + `, + variables: { q }, + }), + }); + if (!res.ok) throw new Error(`Subsquid HTTP ${res.status}`); + const data = await res.json(); + const node = data?.data?.identities?.nodes?.[0]; + if (!node) return null; + + const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId; + duniterKey = ss58ToDuniterKey(genesisKey); + identityName = node.name as string; + } + + const geoMap = await resolveGeoByKeys([duniterKey]); + const geo = geoMap.get(duniterKey); + if (!geo) return null; + + return { name: identityName, city: geo.city.split(',')[0].trim() }; +} + +export function SearchBar({ onResult }: SearchBarProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [found, setFound] = useState<{ name: string; city: string } | null>(null); + + const close = () => { setOpen(false); setQuery(''); setError(null); setFound(null); }; + + const handleSubmit = async () => { + if (!query.trim()) return; + setLoading(true); + setError(null); + setFound(null); + try { + const result = await resolveQuery(query); + if (result) setFound(result); + else setError('Introuvable dans Cesium+'); + } catch { + setError('Erreur de connexion'); + } finally { + setLoading(false); + } + }; + + const handleSelect = () => { + if (!found) return; + onResult(found.city); + close(); + }; + + if (!open) { + return ( + + ); + } + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSubmit(); + if (e.key === 'Escape') close(); + }} + placeholder="Nom ou clé g1…" + className="flex-1 min-w-0 bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-2 py-1.5 text-xs text-white placeholder-[#4b5563] focus:outline-none focus:border-[#d4a843] transition-colors" + /> + + +
+ + {error && ( +

{error}

+ )} + + {found && ( + + )} +
+ ); +} diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx new file mode 100644 index 0000000..fbfd2f0 --- /dev/null +++ b/src/components/Sparkline.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; + +interface SparklineProps { + timestamps: number[]; + periodDays: number; +} + +/** + * Mini bar-chart SVG affichant l'activité journalière sur la période. + * Utilise les timestamps déjà en mémoire — aucune requête supplémentaire. + */ +export function Sparkline({ timestamps, periodDays }: SparklineProps) { + const buckets = useMemo(() => { + if (timestamps.length === 0) return []; + const n = periodDays === 1 ? 24 : Math.min(periodDays, 30); + const now = Date.now(); + const start = now - periodDays * 864e5; + const step = (periodDays * 864e5) / n; + const counts = new Array(n).fill(0); + for (const ts of timestamps) { + const i = Math.floor((ts - start) / step); + if (i >= 0 && i < n) counts[i]++; + } + return counts; + }, [timestamps, periodDays]); + + if (buckets.length === 0) return null; + + const n = buckets.length; + const max = Math.max(...buckets, 1); + const W = 100; + const H = 32; + const barW = W / n; + const gap = barW * 0.18; + + return ( +
+ +
+ {periodDays === 1 ? '0h' : 'J-' + periodDays} + {periodDays === 1 ? 'maintenant' : 'aujourd\'hui'} +
+
+ ); +} diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index 37d50fc..53bd3ad 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -1,6 +1,7 @@ import { useRef } from 'react'; import type { PeriodStats } from '../services/DataService'; import type { FlowStats } from '../data/arcData'; +import { Sparkline } from './Sparkline'; interface StatsPanelProps { stats: PeriodStats | null; @@ -14,6 +15,7 @@ interface StatsPanelProps { flowStats?: FlowStats | null; focusCity?: string | null; onClose?: () => void; + allTimestamps?: number[]; } const MEDALS = ['🥇', '🥈', '🥉']; @@ -60,7 +62,7 @@ function CityRow({ city, volume, count, countryCode, accent }: { ); } -export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className }: StatsPanelProps) { +export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className, allTimestamps = [] }: StatsPanelProps) { const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const prevStats = useRef(null); @@ -113,13 +115,18 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim Visualisation en temps réel des flux de la monnaie libre Ğ1 sur une carte mondiale.

- {/* Period label */} -

- {animationLabel - ? <> {animationLabel} - : <>Période : {periodLabel} - } -

+ {/* Period label + sparkline */} +
+

+ {animationLabel + ? <> {animationLabel} + : <>Période : {periodLabel} + } +

+ {!animationLabel && allTimestamps.length > 0 && ( + + )} +
{/* ---- Vue HEATMAP ---- */} {viewMode === 'heatmap' && ( diff --git a/src/hooks/useUrlState.ts b/src/hooks/useUrlState.ts new file mode 100644 index 0000000..061dd92 --- /dev/null +++ b/src/hooks/useUrlState.ts @@ -0,0 +1,39 @@ +/** + * useUrlState — synchronisation bidirectionnelle de l'état App ↔ URL. + * + * Lecture initiale : appelée une fois au démarrage (module-level). + * Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour. + * + * Paramètres supportés : + * ?period=7&view=flow&city=Paris + */ +import { useEffect } from 'react'; + +function parseInitialState(): { period: number; view: 'heatmap' | 'flow'; city: string | null } { + const p = new URLSearchParams(window.location.search); + const period = parseInt(p.get('period') ?? '', 10); + return { + period: Number.isFinite(period) && period >= 1 && period <= 365 ? period : 7, + view: p.get('view') === 'flow' ? 'flow' : 'heatmap', + city: p.get('city') ?? null, + }; +} + +/** Valeurs lues depuis l'URL au chargement de la page. */ +export const initialUrlState = parseInitialState(); + +/** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */ +export function useUrlSync( + periodDays: number, + viewMode: 'heatmap' | 'flow', + focusCity: string | null, +) { + useEffect(() => { + const p = new URLSearchParams(); + if (periodDays !== 7) p.set('period', String(periodDays)); + if (viewMode !== 'heatmap') p.set('view', viewMode); + if (focusCity) p.set('city', focusCity); + const qs = p.toString(); + history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); + }, [periodDays, viewMode, focusCity]); +} -- 2.39.5 From 0136ff9ce165fac9e93e2530aa1bc5ef207c5d55 Mon Sep 17 00:00:00 2001 From: syoul Date: Sat, 28 Mar 2026 12:32:53 +0100 Subject: [PATCH 2/5] =?UTF-8?q?docs:=20mettre=20=C3=A0=20jour=20InfoPanel?= =?UTF-8?q?=20avec=20les=20nouvelles=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raccourcis clavier, recherche identité (⌕), URL partageable et sparkline documentés dans le panneau d'aide. Co-Authored-By: Claude Sonnet 4.6 --- src/components/InfoPanel.tsx | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/components/InfoPanel.tsx b/src/components/InfoPanel.tsx index 82563c5..6c52a8e 100644 --- a/src/components/InfoPanel.tsx +++ b/src/components/InfoPanel.tsx @@ -83,11 +83,42 @@ export function InfoPanel({ onClose }: InfoPanelProps) { +
+ + frame précédente / suivante · + Espace lecture / pause. + + + H basculer Heatmap ↔ Flux · + Échap quitter l'animation ou fermer ce panneau. + +
+ +
+ + Le bouton (à gauche de la carte) accepte un nom d'identité Ğ1 + (ex : "Alice") ou une clé publique g1…. + Il bascule automatiquement en vue Flux et met la ville en focus. + +
+ +
+ + L'URL reflète l'état courant : période, vue, ville sélectionnée. + Partager l'URL restitue exactement la même configuration. + Exemple : ?period=30&view=flow&city=Paris + +
+
Volume total en Ğ1, nombre de transactions, top émetteurs et receveurs, répartition géographique. Se met à jour en temps réel et pendant l'animation. + + Mini-graphique d'activité journalière affiché sous la période, + calculé depuis les timestamps déjà en mémoire. + Le panneau est accessible via le bouton en haut à gauche. -- 2.39.5 From 7c9d626b98e3ed26d1629474252ff44d63dbdef1 Mon Sep 17 00:00:00 2001 From: syoul Date: Sat, 28 Mar 2026 12:57:19 +0100 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20vue=20dividende=20universel=20?= =?UTF-8?q?=E2=80=94=20overlay=20membres=20actifs=20g=C3=A9olocalis=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bouton DU (gauche carte) : affiche en overlay des cercles verts proportionnels au nombre de membres WoT actifs géolocalisés par ville. Chargement à la demande, mis en cache 1h. Pipeline : SubsquidAdapter.fetchActiveMemberKeys() → isMember:true (~7000) CesiumAdapter.resolveGeoByKeysBatched() → lots de 500 clés DataService.fetchMemberCities() → agrégation + cache 1h HeatMap → CircleMarkers Leaflet en overlay Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 42 ++++++++++++++++++-- src/components/HeatMap.tsx | 31 ++++++++++++++- src/services/DataService.ts | 49 +++++++++++++++++++++++- src/services/adapters/CesiumAdapter.ts | 17 ++++++++ src/services/adapters/SubsquidAdapter.ts | 35 +++++++++++++++++ 5 files changed, 168 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 89c6776..8366fa6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,8 @@ import { HeatMap } from './components/HeatMap'; import { FlowMap } from './components/FlowMap'; import { AnimationPlayer } from './components/AnimationPlayer'; import { SearchBar } from './components/SearchBar'; -import { fetchData } from './services/DataService'; -import type { PeriodStats } from './services/DataService'; +import { fetchData, fetchMemberCities } from './services/DataService'; +import type { PeriodStats, MemberCity } from './services/DataService'; import type { Transaction } from './data/mockData'; import type { TransactionArc } from './data/arcData'; import { computeStats } from './data/mockData'; @@ -31,8 +31,26 @@ export default function App() { const [focusCity, setFocusCity] = useState(initialUrlState.city); const [panelOpen, setPanelOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false); + const [showMembers, setShowMembers] = useState(false); + const [memberCities, setMemberCities] = useState([]); + const [membersLoading, setMembersLoading] = useState(false); const isMobile = useMediaQuery('(max-width: 639px)'); + const toggleMembers = async () => { + if (showMembers) { setShowMembers(false); return; } + if (memberCities.length > 0) { setShowMembers(true); return; } + setMembersLoading(true); + try { + const cities = await fetchMemberCities(); + setMemberCities(cities); + setShowMembers(true); + } catch (err) { + console.warn('fetchMemberCities error:', err); + } finally { + setMembersLoading(false); + } + }; + const animation = useAnimation(transactions, arcs, periodDays, allTimestamps); // Synchronise l'état dans l'URL (deep link / partage) @@ -142,7 +160,10 @@ export default function App() { {/* Map area */}
{viewMode === 'heatmap' ? ( - + ) : (
+ {/* Toggle overlay membres DU */} + + {/* Period selector — floating over map */}
(null); const mapRef = useRef(null); const heatRef = useRef(null); + const memberLayerRef = useRef(null); // Two img overlays that cross-fade between each other. // The canvas opacity is NEVER touched — it stays at leaflet's default. const prevRef = useRef(null); @@ -59,9 +62,11 @@ export function HeatMap({ transactions }: HeatMapProps) { L.control.zoom({ position: 'bottomright' }).addTo(map); const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map); + const memberLayer = L.layerGroup().addTo(map); mapRef.current = map; heatRef.current = heat; + memberLayerRef.current = memberLayer; // Pendant zoom/pan : cache les overlays → le canvas live est visible directement. // Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné. @@ -100,9 +105,33 @@ export function HeatMap({ transactions }: HeatMapProps) { map.remove(); mapRef.current = null; heatRef.current = null; + memberLayerRef.current = null; }; }, []); + // Overlay membres DU : cercles proportionnels au nombre de membres par ville + useEffect(() => { + const layer = memberLayerRef.current; + if (!layer) return; + layer.clearLayers(); + if (memberCities.length === 0) return; + + const maxCount = Math.max(...memberCities.map((c) => c.count), 1); + for (const city of memberCities) { + const radius = 4 + Math.sqrt(city.count / maxCount) * 18; + L.circleMarker([city.lat, city.lng], { + radius, + color: '#00c853', + fillColor: '#00c853', + fillOpacity: 0.18, + weight: 1.5, + opacity: 0.7, + }) + .bindTooltip(`${city.city}
${city.count} membre${city.count > 1 ? 's' : ''}`, { sticky: true }) + .addTo(layer); + } + }, [memberCities]); + // Crossfade: two img overlays swap roles each frame. // Canvas is never hidden — we only read its pixel data via toDataURL(). useEffect(() => { diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 9d199d0..1055cd4 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -12,8 +12,8 @@ * Pour activer : définir VITE_USE_LIVE_API=true dans .env.local */ -import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey } from './adapters/SubsquidAdapter'; -import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter'; +import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter'; +import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter'; import { getTransactionsForPeriod, computeStats, @@ -139,6 +139,51 @@ async function fetchLiveTransactions(periodDays: number): Promise<{ return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) }; } +// --------------------------------------------------------------------------- +// Vue dividende universel : membres actifs géolocalisés par ville +// --------------------------------------------------------------------------- + +export interface MemberCity { + city: string; + lat: number; + lng: number; + count: number; + countryCode: string; +} + +let memberCitiesCache: { data: MemberCity[]; expiresAt: number } | null = null; + +/** + * Retourne la liste des villes avec le nombre de membres WoT actifs géolocalisés. + * Résultat mis en cache 1 heure (le nombre de membres évolue lentement). + * Traite les ~7000 clés en lots de 500 pour ne pas surcharger Cesium+. + */ +export async function fetchMemberCities(): Promise { + if (memberCitiesCache && Date.now() < memberCitiesCache.expiresAt) return memberCitiesCache.data; + + const duniterKeys = await fetchActiveMemberKeys(); + const unique = [...new Set(duniterKeys)]; + const geoMap = await resolveGeoByKeysBatched(unique); + + const cityMap = new Map(); + for (const geo of geoMap.values()) { + const city = cleanCityName(geo.city); + const existing = cityMap.get(city); + if (existing) { + existing.count++; + } else { + cityMap.set(city, { lat: geo.lat, lng: geo.lng, count: 1, countryCode: geo.countryCode }); + } + } + + const data: MemberCity[] = [...cityMap.entries()] + .map(([city, v]) => ({ city, ...v })) + .sort((a, b) => b.count - a.count); + + memberCitiesCache = { data, expiresAt: Date.now() + 60 * 60 * 1000 }; + return data; +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- diff --git a/src/services/adapters/CesiumAdapter.ts b/src/services/adapters/CesiumAdapter.ts index a2257d7..4f73071 100644 --- a/src/services/adapters/CesiumAdapter.ts +++ b/src/services/adapters/CesiumAdapter.ts @@ -163,6 +163,23 @@ export async function resolveGeoByKeys( 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> { + const result = new Map(); + 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. diff --git a/src/services/adapters/SubsquidAdapter.ts b/src/services/adapters/SubsquidAdapter.ts index ecc9edb..4a3c5f4 100644 --- a/src/services/adapters/SubsquidAdapter.ts +++ b/src/services/adapters/SubsquidAdapter.ts @@ -174,6 +174,41 @@ export async function fetchCurrentUD(): Promise { } } +// --------------------------------------------------------------------------- +// Membres actifs WoT (isMember = true) +// --------------------------------------------------------------------------- + +const ACTIVE_MEMBERS_QUERY = ` + query { + identities(filter: { isMember: { equalTo: true } }, first: 20000) { + nodes { + accountId + ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) { + nodes { previousId } + } + } + } + } +`; + +/** Retourne la liste des clés SS58 de tous les membres WoT actifs. */ +export async function fetchActiveMemberKeys(): Promise { + const res = await fetch(SUBSQUID_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: ACTIVE_MEMBERS_QUERY }), + }); + if (!res.ok) throw new Error(`Subsquid HTTP ${res.status}`); + const raw = await res.json(); + if (raw.errors?.length) throw new Error(raw.errors[0].message); + + return (raw.data.identities.nodes as { accountId: string; ownerKeyChange: { nodes: { previousId: string }[] } }[]) + .map((node) => { + const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId; + return ss58ToDuniterKey(genesisKey); + }); +} + export interface FetchTransfersResult { transfers: RawTransfer[]; totalCount: number; -- 2.39.5 From 0d9415ae6ae20ea45fb61b127f8ef359d0f1177f Mon Sep 17 00:00:00 2001 From: syoul Date: Tue, 21 Apr 2026 20:43:33 +0200 Subject: [PATCH 4/5] feat: indicateurs de statut et configuration des endpoints SubSquid/Cesium+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 4 +- src/components/EndpointPopover.tsx | 170 +++++++++++++++++++++++ src/components/InfoPanel.tsx | 18 ++- src/components/ServiceStatusDots.tsx | 87 ++++++++++++ src/components/StatsPanel.tsx | 12 +- src/hooks/useServiceStatus.ts | 118 ++++++++++++++++ src/services/EndpointConfig.ts | 35 +++++ src/services/adapters/CesiumAdapter.ts | 5 +- src/services/adapters/SubsquidAdapter.ts | 9 +- 9 files changed, 448 insertions(+), 10 deletions(-) create mode 100644 src/components/EndpointPopover.tsx create mode 100644 src/components/ServiceStatusDots.tsx create mode 100644 src/hooks/useServiceStatus.ts create mode 100644 src/services/EndpointConfig.ts diff --git a/src/App.tsx b/src/App.tsx index 8366fa6..52a282d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ export default function App() { const [showMembers, setShowMembers] = useState(false); const [memberCities, setMemberCities] = useState([]); const [membersLoading, setMembersLoading] = useState(false); + const [endpointVersion, setEndpointVersion] = useState(0); const isMobile = useMediaQuery('(max-width: 639px)'); const toggleMembers = async () => { @@ -119,7 +120,7 @@ export default function App() { const interval = setInterval(() => load(false), 30_000); return () => { cancelled = true; clearInterval(interval); }; - }, [periodDays]); + }, [periodDays, endpointVersion]); // Stats heatmap sur la fenêtre courante en mode animation const visibleStats: PeriodStats | null = animation.active @@ -150,6 +151,7 @@ export default function App() { flowStats, focusCity, allTimestamps, + onEndpointChange: () => setEndpointVersion((v) => v + 1), }; return ( diff --git a/src/components/EndpointPopover.tsx b/src/components/EndpointPopover.tsx new file mode 100644 index 0000000..cb32350 --- /dev/null +++ b/src/components/EndpointPopover.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + KNOWN_SUBSQUID_NODES, + KNOWN_CESIUM_NODES, + getSubsquidUrl, + getCesiumUrl, + setSubsquidUrl, + setCesiumUrl, +} from '../services/EndpointConfig'; +import { testEndpoint } from '../hooks/useServiceStatus'; + +interface Props { + service: 'subsquid' | 'cesium'; + onClose: () => void; + onSaved: () => void; +} + +interface TestResult { + url: string; + state: 'testing' | 'ok' | 'slow' | 'error'; + latencyMs: number | null; +} + +const LABELS = { subsquid: 'SubSquid', cesium: 'Cesium+' }; + +export function EndpointPopover({ service, onClose, onSaved }: Props) { + const currentUrl = service === 'subsquid' ? getSubsquidUrl() : getCesiumUrl(); + const knownNodes = service === 'subsquid' ? KNOWN_SUBSQUID_NODES : KNOWN_CESIUM_NODES; + + const [inputUrl, setInputUrl] = useState(currentUrl); + const [testResults, setTestResults] = useState>(new Map()); + + const runTest = async (url: string) => { + setTestResults((prev) => new Map(prev).set(url, { url, state: 'testing', latencyMs: null })); + try { + const ms = await testEndpoint(service, url); + setTestResults((prev) => + new Map(prev).set(url, { url, state: ms < 2000 ? 'ok' : 'slow', latencyMs: ms }) + ); + } catch { + setTestResults((prev) => new Map(prev).set(url, { url, state: 'error', latencyMs: null })); + } + }; + + const handleSave = () => { + const trimmed = inputUrl.trim(); + if (!trimmed) return; + if (service === 'subsquid') setSubsquidUrl(trimmed); + else setCesiumUrl(trimmed); + onSaved(); + onClose(); + }; + + const dot = (state: TestResult['state']) => { + if (state === 'testing') return ; + if (state === 'ok') return ; + if (state === 'slow') return ; + return ; + }; + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ + {/* Header */} +
+

+ Configurer {LABELS[service]} +

+ +
+ + {/* Nœuds connus */} +
+

Nœuds connus

+ {knownNodes.map((node) => { + const result = testResults.get(node.url); + const isActive = inputUrl === node.url; + return ( +
setInputUrl(node.url)} + > +
+

{node.label}

+

{node.url}

+
+
+ {result && ( + + {dot(result.state)} + {result.latencyMs !== null && ` ${result.latencyMs} ms`} + + )} + +
+
+ ); + })} +
+ + {/* URL personnalisée */} +
+

URL personnalisée

+
+ setInputUrl(e.target.value)} + placeholder={service === 'subsquid' ? 'https://…/v1/graphql' : 'https://…'} + className="flex-1 bg-[#0a0b0f] border border-[#2e2f3a] rounded-xl px-3 py-2 text-white text-sm font-mono placeholder-[#2e2f3a] focus:outline-none focus:border-[#d4a843]/60 transition-colors" + /> + +
+ {(() => { + const result = testResults.get(inputUrl.trim()); + if (!result || knownNodes.some((n) => n.url === inputUrl.trim())) return null; + return ( +

+ {dot(result.state)} + {result.state === 'testing' && ' Test en cours…'} + {result.state === 'ok' && ` OK · ${result.latencyMs} ms`} + {result.state === 'slow' && ` Lent · ${result.latencyMs} ms`} + {result.state === 'error' && ' Inaccessible'} +

+ ); + })()} +
+ + {/* Actions */} +
+ + +
+
+
, + document.body + ); +} diff --git a/src/components/InfoPanel.tsx b/src/components/InfoPanel.tsx index 6c52a8e..96399de 100644 --- a/src/components/InfoPanel.tsx +++ b/src/components/InfoPanel.tsx @@ -127,9 +127,25 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
+
+ + Le bouton DU (à gauche de la carte) affiche en overlay les membres Ğ1 + actifs (WoT) ayant un profil Cesium+ géolocalisé. + Chaque point représente une ville avec des membres actifs. + +
+
- Données temps réel de la blockchain Ğ1v2, actualisées toutes les 30 secondes. + Données temps réel de la blockchain Ğ1v2 via SubSquid, actualisées toutes les 30 secondes. + Les profils de géolocalisation sont fournis par Cesium+. + + + Deux indicateurs en bas du panneau latéral affichent l'état de SubSquid et Cesium+ en temps réel + (vert OK ·{' '} + jaune lent ·{' '} + rouge inaccessible). + Un clic sur un indicateur permet de configurer ou changer l'endpoint.
diff --git a/src/components/ServiceStatusDots.tsx b/src/components/ServiceStatusDots.tsx new file mode 100644 index 0000000..477b0fd --- /dev/null +++ b/src/components/ServiceStatusDots.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import type { ServiceStatus, ServiceState } from '../hooks/useServiceStatus'; +import { EndpointPopover } from './EndpointPopover'; + +interface Props { + subsquid: ServiceStatus; + cesium: ServiceStatus; + onEndpointChange: () => void; +} + +const STATE_COLOR: Record = { + checking: 'text-[#4b5563] animate-pulse', + ok: 'text-emerald-400', + slow: 'text-amber-400', + error: 'text-red-500', +}; + +function Dot({ status, label, onClick }: { status: ServiceStatus; label: string; onClick: () => void }) { + const latency = status.latencyMs !== null ? ` · ${status.latencyMs} ms` : ''; + const title = `${label} — ${STATUS_LABEL_FULL[status.state]}${latency}\n${status.url}`; + + return ( + + ); +} + +const STATUS_LABEL_FULL: Record = { + checking: 'Vérification…', + ok: 'Accessible', + slow: 'Réponse lente', + error: 'Inaccessible', +}; + +export function ServiceStatusDots({ subsquid, cesium, onEndpointChange }: Props) { + const [popover, setPopover] = useState<'subsquid' | 'cesium' | null>(null); + + const hasError = subsquid.state === 'error' || cesium.state === 'error'; + const erroredService = subsquid.state === 'error' ? 'SubSquid' : 'Cesium+'; + const erroredKey: 'subsquid' | 'cesium' = subsquid.state === 'error' ? 'subsquid' : 'cesium'; + + return ( + <> + {/* Bannière d'erreur */} + {hasError && ( +
+ + + {erroredService} inaccessible + + +
+ )} + + {/* Dots */} +
+ setPopover('subsquid')} /> + setPopover('cesium')} /> +
+ + {/* Popover de configuration */} + {popover && ( + setPopover(null)} + onSaved={onEndpointChange} + /> + )} + + ); +} diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index 53bd3ad..364ea64 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -2,6 +2,8 @@ import { useRef } from 'react'; import type { PeriodStats } from '../services/DataService'; import type { FlowStats } from '../data/arcData'; import { Sparkline } from './Sparkline'; +import { ServiceStatusDots } from './ServiceStatusDots'; +import { useServiceStatus } from '../hooks/useServiceStatus'; interface StatsPanelProps { stats: PeriodStats | null; @@ -15,6 +17,7 @@ interface StatsPanelProps { flowStats?: FlowStats | null; focusCity?: string | null; onClose?: () => void; + onEndpointChange?: () => void; allTimestamps?: number[]; } @@ -62,7 +65,8 @@ function CityRow({ city, volume, count, countryCode, accent }: { ); } -export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className, allTimestamps = [] }: StatsPanelProps) { +export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, onEndpointChange, className, allTimestamps = [] }: StatsPanelProps) { + const { subsquid, cesium, recheck } = useServiceStatus(); const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const prevStats = useRef(null); @@ -97,7 +101,11 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim Ğ1Flux v{__APP_VERSION__} -

Monnaie libre · Flux géo

+ { recheck(); onEndpointChange?.(); }} + />
{onClose && (