From 7c9d626b98e3ed26d1629474252ff44d63dbdef1 Mon Sep 17 00:00:00 2001 From: syoul Date: Sat, 28 Mar 2026 12:57:19 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20vue=20dividende=20universel=20=E2=80=94?= =?UTF-8?q?=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;