feat: vue dividende universel — overlay membres actifs géolocalisés
ci/woodpecker/push/woodpecker Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-28 12:57:19 +01:00
parent 0136ff9ce1
commit 7c9d626b98
5 changed files with 168 additions and 6 deletions
+39 -3
View File
@@ -5,8 +5,8 @@ import { HeatMap } from './components/HeatMap';
import { FlowMap } from './components/FlowMap'; import { FlowMap } from './components/FlowMap';
import { AnimationPlayer } from './components/AnimationPlayer'; import { AnimationPlayer } from './components/AnimationPlayer';
import { SearchBar } from './components/SearchBar'; import { SearchBar } from './components/SearchBar';
import { fetchData } from './services/DataService'; import { fetchData, fetchMemberCities } from './services/DataService';
import type { PeriodStats } from './services/DataService'; import type { PeriodStats, MemberCity } from './services/DataService';
import type { Transaction } from './data/mockData'; import type { Transaction } from './data/mockData';
import type { TransactionArc } from './data/arcData'; import type { TransactionArc } from './data/arcData';
import { computeStats } from './data/mockData'; import { computeStats } from './data/mockData';
@@ -31,8 +31,26 @@ export default function App() {
const [focusCity, setFocusCity] = useState<string | null>(initialUrlState.city); const [focusCity, setFocusCity] = useState<string | null>(initialUrlState.city);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false);
const [showMembers, setShowMembers] = useState(false);
const [memberCities, setMemberCities] = useState<MemberCity[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const isMobile = useMediaQuery('(max-width: 639px)'); 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); const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
// Synchronise l'état dans l'URL (deep link / partage) // Synchronise l'état dans l'URL (deep link / partage)
@@ -142,7 +160,10 @@ export default function App() {
{/* Map area */} {/* Map area */}
<div className="relative flex-1 min-w-0"> <div className="relative flex-1 min-w-0">
{viewMode === 'heatmap' ? ( {viewMode === 'heatmap' ? (
<HeatMap transactions={animation.visibleTransactions} /> <HeatMap
transactions={animation.visibleTransactions}
memberCities={showMembers ? memberCities : []}
/>
) : ( ) : (
<FlowMap <FlowMap
arcs={animation.active ? animation.visibleArcs : arcs} arcs={animation.active ? animation.visibleArcs : arcs}
@@ -181,6 +202,21 @@ export default function App() {
/> />
</div> </div>
{/* Toggle overlay membres DU */}
<button
onClick={toggleMembers}
disabled={membersLoading}
title={showMembers ? 'Masquer les membres' : 'Afficher les membres Ğ1 actifs géolocalisés'}
className={`absolute ${isMobile ? 'top-40' : 'top-28'} left-4 z-[1001] w-10 h-10 backdrop-blur-sm border rounded-xl flex items-center justify-center text-sm transition-colors
${showMembers
? 'bg-[#00c853]/20 border-[#00c853]/60 text-[#00c853]'
: 'bg-[#0a0b0f]/90 border-[#2e2f3a] text-[#6b7280] hover:text-[#00c853]'
}`}
aria-label="Membres DU"
>
{membersLoading ? <span className="animate-spin inline-block text-xs"></span> : 'DU'}
</button>
{/* Period selector — floating over map */} {/* Period selector — floating over map */}
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}> <div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
<PeriodSelector <PeriodSelector
+30 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet.heat'; import 'leaflet.heat';
import type { Transaction } from '../data/mockData'; import type { Transaction } from '../data/mockData';
import type { MemberCity } from '../services/DataService';
// Leaflet default marker fix (Vite asset pipeline) // Leaflet default marker fix (Vite asset pipeline)
import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconUrl from 'leaflet/dist/images/marker-icon.png';
@@ -10,6 +11,7 @@ L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl });
interface HeatMapProps { interface HeatMapProps {
transactions: Transaction[]; transactions: Transaction[];
memberCities?: MemberCity[];
} }
const HEAT_OPTIONS: L.HeatMapOptions = { const HEAT_OPTIONS: L.HeatMapOptions = {
@@ -29,10 +31,11 @@ const HEAT_OPTIONS: L.HeatMapOptions = {
}, },
}; };
export function HeatMap({ transactions }: HeatMapProps) { export function HeatMap({ transactions, memberCities = [] }: HeatMapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null); const mapRef = useRef<L.Map | null>(null);
const heatRef = useRef<L.HeatLayer | null>(null); const heatRef = useRef<L.HeatLayer | null>(null);
const memberLayerRef = useRef<L.LayerGroup | null>(null);
// Two img overlays that cross-fade between each other. // Two img overlays that cross-fade between each other.
// The canvas opacity is NEVER touched — it stays at leaflet's default. // The canvas opacity is NEVER touched — it stays at leaflet's default.
const prevRef = useRef<HTMLImageElement | null>(null); const prevRef = useRef<HTMLImageElement | null>(null);
@@ -59,9 +62,11 @@ export function HeatMap({ transactions }: HeatMapProps) {
L.control.zoom({ position: 'bottomright' }).addTo(map); L.control.zoom({ position: 'bottomright' }).addTo(map);
const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map); const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map);
const memberLayer = L.layerGroup().addTo(map);
mapRef.current = map; mapRef.current = map;
heatRef.current = heat; heatRef.current = heat;
memberLayerRef.current = memberLayer;
// Pendant zoom/pan : cache les overlays → le canvas live est visible directement. // Pendant zoom/pan : cache les overlays → le canvas live est visible directement.
// Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné. // Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné.
@@ -100,9 +105,33 @@ export function HeatMap({ transactions }: HeatMapProps) {
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
heatRef.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(`<b>${city.city}</b><br/>${city.count} membre${city.count > 1 ? 's' : ''}`, { sticky: true })
.addTo(layer);
}
}, [memberCities]);
// Crossfade: two img overlays swap roles each frame. // Crossfade: two img overlays swap roles each frame.
// Canvas is never hidden — we only read its pixel data via toDataURL(). // Canvas is never hidden — we only read its pixel data via toDataURL().
useEffect(() => { useEffect(() => {
+47 -2
View File
@@ -12,8 +12,8 @@
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local * Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/ */
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey } from './adapters/SubsquidAdapter'; import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey, fetchActiveMemberKeys } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter'; import { resolveGeoByKeys, resolveGeoByKeysBatched, cleanCityName } from './adapters/CesiumAdapter';
import { import {
getTransactionsForPeriod, getTransactionsForPeriod,
computeStats, computeStats,
@@ -139,6 +139,51 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) }; 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<MemberCity[]> {
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<string, { lat: number; lng: number; count: number; countryCode: string }>();
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 // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+17
View File
@@ -163,6 +163,23 @@ export async function resolveGeoByKeys(
return result; 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<Map<string, GeoProfile>> {
const result = new Map<string, GeoProfile>();
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é. * 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. * Envoie une requête Elasticsearch multi-terms en un seul appel.
+35
View File
@@ -174,6 +174,41 @@ export async function fetchCurrentUD(): Promise<number> {
} }
} }
// ---------------------------------------------------------------------------
// 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<string[]> {
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 { export interface FetchTransfersResult {
transfers: RawTransfer[]; transfers: RawTransfer[];
totalCount: number; totalCount: number;