dev #1
+39
-3
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user