feat: vue dividende universel — overlay membres actifs géolocalisés
ci/woodpecker/push/woodpecker Pipeline was successful
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:
+39
-3
@@ -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<string | null>(initialUrlState.city);
|
||||
const [panelOpen, setPanelOpen] = 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 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 */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{viewMode === 'heatmap' ? (
|
||||
<HeatMap transactions={animation.visibleTransactions} />
|
||||
<HeatMap
|
||||
transactions={animation.visibleTransactions}
|
||||
memberCities={showMembers ? memberCities : []}
|
||||
/>
|
||||
) : (
|
||||
<FlowMap
|
||||
arcs={animation.active ? animation.visibleArcs : arcs}
|
||||
@@ -181,6 +202,21 @@ export default function App() {
|
||||
/>
|
||||
</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 */}
|
||||
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
||||
<PeriodSelector
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet.heat';
|
||||
import type { Transaction } from '../data/mockData';
|
||||
import type { MemberCity } from '../services/DataService';
|
||||
|
||||
// Leaflet default marker fix (Vite asset pipeline)
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
@@ -10,6 +11,7 @@ L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl });
|
||||
|
||||
interface HeatMapProps {
|
||||
transactions: Transaction[];
|
||||
memberCities?: MemberCity[];
|
||||
}
|
||||
|
||||
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 mapRef = useRef<L.Map | 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.
|
||||
// The canvas opacity is NEVER touched — it stays at leaflet's default.
|
||||
const prevRef = useRef<HTMLImageElement | null>(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(`<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.
|
||||
// Canvas is never hidden — we only read its pixel data via toDataURL().
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<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é.
|
||||
* Envoie une requête Elasticsearch multi-terms en un seul appel.
|
||||
|
||||
@@ -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 {
|
||||
transfers: RawTransfer[];
|
||||
totalCount: number;
|
||||
|
||||
Reference in New Issue
Block a user