Compare commits
48 Commits
v1.0.0
...
ac168c3689
| Author | SHA1 | Date | |
|---|---|---|---|
| ac168c3689 | |||
| 8f9a11c4e8 | |||
| 63f50d5762 | |||
| 9f3752b621 | |||
| 6fc1705f6d | |||
| 15807c7bcb | |||
| bac113e51b | |||
| 6d01c8d29e | |||
| 46b11710cc | |||
| 78ede01d11 | |||
| 70de7e4c06 | |||
| 65f26e2b58 | |||
| 104949427c | |||
| 9ec95dfc91 | |||
| ab1bad2209 | |||
| 3dbd8704ff | |||
| b0104207c4 | |||
| 00f0602c61 | |||
| 4821dab6e6 | |||
| eb4e693f3c | |||
| 810c815706 | |||
| 0e040510af | |||
| ac974fb8a0 | |||
| 666dc99989 | |||
| c02f207b6c | |||
| 77f5f44758 | |||
| c54e76bb04 | |||
| b7e8bade97 | |||
| 64682ea773 | |||
| 53b1e9b399 | |||
| c51bb251e3 | |||
| 851dc46394 | |||
| 786bf30a7b | |||
| 839acf8aa8 | |||
| ffe09ea44a | |||
| e4eb02560a | |||
| 8e208d02ab | |||
| 16cebb6ec9 | |||
| b6cb0af722 | |||
| 78b4762c88 | |||
| 5978ddfed3 | |||
| 136571ed53 | |||
| b884884a04 | |||
| 97ff22027c | |||
| ab72d8218b | |||
| 57c1888346 | |||
| 7ee3b09f0f | |||
| 21441c4550 |
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
docs-plan
|
||||
docs-bugs
|
||||
docs-syoul
|
||||
*.md
|
||||
.env*
|
||||
+6
-4
@@ -45,20 +45,22 @@ steps:
|
||||
|
||||
# Etape 4a : Generation SBOM (Syft) depuis l'image locale
|
||||
# NOTE: volumes + pas de from_secret : compatible
|
||||
# Utilise l'image officielle anchore/syft pour eviter le bug d'auto-detection
|
||||
# de container (signal Go imprime en adresse memoire sur alpine + curl install)
|
||||
- name: sbom-generate
|
||||
image: alpine:3.20
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
- apk add --no-cache curl
|
||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||
- apk add --no-cache curl tar
|
||||
- curl -sSfL "https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_linux_amd64.tar.gz" | tar xz -C /usr/local/bin syft
|
||||
- mkdir -p .reports
|
||||
- syft g1flux:latest -o cyclonedx-json --file .reports/sbom-app.cyclonedx.json
|
||||
- syft packages docker:g1flux:latest -o cyclonedx-json=.reports/sbom-app.cyclonedx.json
|
||||
- echo "SBOM genere"
|
||||
|
||||
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
|
||||
- name: sbom-scan
|
||||
image: aquasec/trivy:latest
|
||||
image: aquasec/trivy:0.69.3
|
||||
volumes:
|
||||
- /home/syoul/trivy-cache:/root/.cache/trivy
|
||||
commands:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "g1flux",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+114
-19
@@ -1,17 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { StatsPanel } from './components/StatsPanel';
|
||||
import { PeriodSelector } from './components/PeriodSelector';
|
||||
import { HeatMap } from './components/HeatMap';
|
||||
import { FlowMap } from './components/FlowMap';
|
||||
import { AnimationPlayer } from './components/AnimationPlayer';
|
||||
import { fetchData } from './services/DataService';
|
||||
import type { PeriodStats } from './services/DataService';
|
||||
import type { Transaction } from './data/mockData';
|
||||
import type { TransactionArc } from './data/arcData';
|
||||
import { computeStats } from './data/mockData';
|
||||
import { computeFlowStats } from './data/arcData';
|
||||
import { useAnimation } from './hooks/useAnimation';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import { InfoPanel } from './components/InfoPanel';
|
||||
|
||||
export default function App() {
|
||||
const [periodDays, setPeriodDays] = useState(7);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [arcs, setArcs] = useState<TransactionArc[]>([]);
|
||||
const [stats, setStats] = useState<PeriodStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -19,14 +25,24 @@ export default function App() {
|
||||
const [source, setSource] = useState<'live' | 'mock'>('mock');
|
||||
const [currentUD, setCurrentUD] = useState<number>(11.78);
|
||||
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
||||
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
||||
const [focusCity, setFocusCity] = useState<string | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 639px)');
|
||||
|
||||
const animation = useAnimation(transactions, periodDays, allTimestamps);
|
||||
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||
|
||||
const handlePeriodChange = (days: number) => {
|
||||
animation.deactivate();
|
||||
setPeriodDays(days);
|
||||
};
|
||||
|
||||
const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
|
||||
setViewMode(mode);
|
||||
setFocusCity(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -34,9 +50,10 @@ export default function App() {
|
||||
if (showLoading) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
fetchData(periodDays)
|
||||
.then(({ transactions, stats, source, currentUD, allTimestamps }) => {
|
||||
.then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => {
|
||||
if (!cancelled) {
|
||||
setTransactions(transactions);
|
||||
setArcs(arcs);
|
||||
setStats(stats);
|
||||
setSource(source);
|
||||
setCurrentUD(currentUD);
|
||||
@@ -56,44 +73,98 @@ export default function App() {
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [periodDays]);
|
||||
|
||||
// Stats calculées sur la fenêtre courante en mode animation
|
||||
// Stats heatmap sur la fenêtre courante en mode animation
|
||||
const visibleStats: PeriodStats | null = animation.active
|
||||
? {
|
||||
...computeStats(animation.visibleTransactions),
|
||||
geoCount: animation.visibleTransactions.length,
|
||||
// frameTotalCount = total réel (géo + non-géo) dans cette frame
|
||||
transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length,
|
||||
}
|
||||
: stats;
|
||||
|
||||
// Stats flux (recalculées sur les arcs visibles)
|
||||
const flowStats = useMemo(
|
||||
() => {
|
||||
const activeArcs = animation.active ? animation.visibleArcs : arcs;
|
||||
return activeArcs.length > 0 ? computeFlowStats(activeArcs) : null;
|
||||
},
|
||||
[arcs, animation.visibleArcs, animation.active],
|
||||
);
|
||||
|
||||
const statsPanelProps = {
|
||||
stats: visibleStats,
|
||||
loading,
|
||||
periodDays,
|
||||
source,
|
||||
currentUD,
|
||||
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
|
||||
viewMode,
|
||||
flowStats,
|
||||
focusCity,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
||||
{/* Side panel */}
|
||||
<StatsPanel
|
||||
stats={visibleStats}
|
||||
loading={loading}
|
||||
periodDays={periodDays}
|
||||
source={source}
|
||||
currentUD={currentUD}
|
||||
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
||||
/>
|
||||
{/* Side panel — desktop uniquement */}
|
||||
{!isMobile && <StatsPanel {...statsPanelProps} />}
|
||||
|
||||
{/* Map area */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<HeatMap transactions={animation.visibleTransactions} />
|
||||
{viewMode === 'heatmap' ? (
|
||||
<HeatMap transactions={animation.visibleTransactions} />
|
||||
) : (
|
||||
<FlowMap
|
||||
arcs={animation.active ? animation.visibleArcs : arcs}
|
||||
focusCity={focusCity}
|
||||
onCityClick={setFocusCity}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bouton menu — mobile uniquement */}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setPanelOpen(true)}
|
||||
className="absolute top-4 left-4 z-[1001] w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#d4a843] text-lg"
|
||||
aria-label="Ouvrir le panneau"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bouton info — sous ☰ sur mobile, top-left sur desktop */}
|
||||
<button
|
||||
onClick={() => setInfoOpen(true)}
|
||||
className={`absolute ${isMobile ? 'top-16' : 'top-4'} left-4 z-[1001] w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#6b7280] hover:text-[#d4a843] transition-colors text-base`}
|
||||
aria-label="Aide"
|
||||
>
|
||||
ℹ
|
||||
</button>
|
||||
|
||||
{/* Period selector — floating over map */}
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
||||
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
||||
<PeriodSelector
|
||||
value={periodDays}
|
||||
onChange={handlePeriodChange}
|
||||
animationActive={animation.active}
|
||||
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
geoPercent={visibleStats && visibleStats.transactionCount > 0
|
||||
? Math.round((visibleStats.geoCount / visibleStats.transactionCount) * 100)
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transaction count + source badge (masqués en mode animation) */}
|
||||
{!loading && !animation.active && (
|
||||
{/* Badge ville focus — mobile uniquement */}
|
||||
{isMobile && focusCity && (
|
||||
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1001] flex items-center gap-2 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#d4a843]/40 rounded-full px-3 py-1.5">
|
||||
<span className="text-[#d4a843] text-xs font-medium">{focusCity}</span>
|
||||
<button onClick={() => setFocusCity(null)} className="text-[#4b5563] hover:text-white text-xs leading-none">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction count + source badge (masqués sur mobile et en mode animation) */}
|
||||
{!loading && !animation.active && !isMobile && (
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
||||
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
||||
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||
@@ -121,7 +192,6 @@ export default function App() {
|
||||
onPlay={animation.play}
|
||||
onPause={animation.pause}
|
||||
onSpeedChange={animation.setSpeed}
|
||||
onClose={animation.deactivate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -135,6 +205,31 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info panel */}
|
||||
{infoOpen && <InfoPanel onClose={() => setInfoOpen(false)} />}
|
||||
|
||||
{/* Bottom drawer — mobile uniquement */}
|
||||
{isMobile && (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
{panelOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[1009] bg-black/50"
|
||||
onClick={() => setPanelOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={`fixed bottom-0 left-0 right-0 z-[1010] h-[85vh] flex flex-col transition-transform duration-300 ${panelOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
||||
>
|
||||
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
|
||||
<div className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
|
||||
</div>
|
||||
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} className="w-full flex-1 min-h-0" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ interface AnimationPlayerProps {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSpeedChange: (s: 1 | 2 | 4) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AnimationPlayer({
|
||||
@@ -21,13 +20,12 @@ export function AnimationPlayer({
|
||||
onPlay,
|
||||
onPause,
|
||||
onSpeedChange,
|
||||
onClose,
|
||||
}: AnimationPlayerProps) {
|
||||
const frame = frames[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
|
||||
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,calc(100vw-1rem))]">
|
||||
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-4 py-3 flex flex-col gap-2.5 shadow-xl">
|
||||
|
||||
{/* Frame label + position */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -50,7 +48,7 @@ export function AnimationPlayer({
|
||||
/>
|
||||
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-2">
|
||||
|
||||
{/* Playback buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -78,8 +76,7 @@ export function AnimationPlayer({
|
||||
|
||||
{/* Speed selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
|
||||
{([1, 2, 4] as const).map((s) => (
|
||||
{([1, 2, 4] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onSpeedChange(s)}
|
||||
@@ -94,14 +91,6 @@ export function AnimationPlayer({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[#4b5563] hover:text-white transition-colors px-2 py-1 text-sm ml-2"
|
||||
title="Quitter l'animation"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import L from 'leaflet';
|
||||
|
||||
/** Interpolation RGB linéaire entre deux couleurs hex, t ∈ [0, 1]. */
|
||||
function lerpColor(hex1: string, hex2: string, t: number): string {
|
||||
const parse = (h: string) => [
|
||||
parseInt(h.slice(1, 3), 16),
|
||||
parseInt(h.slice(3, 5), 16),
|
||||
parseInt(h.slice(5, 7), 16),
|
||||
];
|
||||
const [r1, g1, b1] = parse(hex1);
|
||||
const [r2, g2, b2] = parse(hex2);
|
||||
const r = Math.round(r1 + (r2 - r1) * t);
|
||||
const g = Math.round(g1 + (g2 - g1) * t);
|
||||
const b = Math.round(b1 + (b2 - b1) * t);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
|
||||
const COLOR_NEG = '#e53935'; // rouge vif
|
||||
const COLOR_POS = '#00c853'; // vert vif
|
||||
const NEUTRAL_THRESHOLD = 0.05; // ±5 % → couleur neutre
|
||||
const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux villes
|
||||
|
||||
import type { TransactionArc } from '../data/arcData';
|
||||
import { buildCorridors } from '../data/arcData';
|
||||
|
||||
// Leaflet default marker fix (Vite asset pipeline)
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconShadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl });
|
||||
|
||||
interface FlowMapProps {
|
||||
arcs: TransactionArc[];
|
||||
focusCity: string | null;
|
||||
onCityClick: (city: string | null) => void;
|
||||
}
|
||||
|
||||
export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [clustered, setClustered] = useState(true);
|
||||
|
||||
// Initialisation Leaflet
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
center: [46.8, 2.35],
|
||||
zoom: 6,
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
setMapReady(true);
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
setMapReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-projette le SVG à chaque déplacement/zoom
|
||||
useEffect(() => {
|
||||
if (!mapReady || !mapRef.current) return;
|
||||
const onMove = () => setTick(t => t + 1);
|
||||
mapRef.current.on('moveend zoomend', onMove);
|
||||
return () => { mapRef.current?.off('moveend zoomend', onMove); };
|
||||
}, [mapReady]);
|
||||
|
||||
// Agrégation en corridors
|
||||
const corridors = useMemo(() => buildCorridors(arcs), [arcs]);
|
||||
|
||||
// Nœuds de villes (volume entrant + sortant)
|
||||
const cityNodes = useMemo(() => {
|
||||
const map = new Map<string, { lat: number; lng: number; emitted: number; received: number }>();
|
||||
for (const c of corridors) {
|
||||
if (!map.has(c.fromCity)) map.set(c.fromCity, { lat: c.fromLat, lng: c.fromLng, emitted: 0, received: 0 });
|
||||
map.get(c.fromCity)!.emitted += c.totalVolume;
|
||||
if (!map.has(c.toCity)) map.set(c.toCity, { lat: c.toLat, lng: c.toLng, emitted: 0, received: 0 });
|
||||
map.get(c.toCity)!.received += c.totalVolume;
|
||||
}
|
||||
return map;
|
||||
}, [corridors]);
|
||||
|
||||
// Projection SVG + clustering (recalculée sur chaque tick, changement d'arcs ou de focusCity)
|
||||
const svgElements = useMemo(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m || !mapReady) return null;
|
||||
|
||||
const proj = (lat: number, lng: number) => {
|
||||
const p = m.latLngToContainerPoint([lat, lng]);
|
||||
return { x: p.x, y: p.y };
|
||||
};
|
||||
|
||||
// --- 1. Projeter toutes les villes en pixels, triées par volume desc ---
|
||||
type CityPx = {
|
||||
name: string; lat: number; lng: number;
|
||||
x: number; y: number;
|
||||
emitted: number; received: number; vol: number;
|
||||
};
|
||||
const cityList: CityPx[] = [...cityNodes.entries()].map(([name, d]) => {
|
||||
const p = proj(d.lat, d.lng);
|
||||
return { name, lat: d.lat, lng: d.lng, x: p.x, y: p.y, emitted: d.emitted, received: d.received, vol: d.emitted + d.received };
|
||||
}).sort((a, b) => b.vol - a.vol);
|
||||
|
||||
// --- 2. Clustering glouton par distance pixel (ou 1 ville = 1 cluster) ---
|
||||
interface Cluster {
|
||||
cx: number; cy: number;
|
||||
lat: number; lng: number;
|
||||
totalVol: number;
|
||||
emitted: number; received: number;
|
||||
cities: Set<string>;
|
||||
}
|
||||
const clusters: Cluster[] = [];
|
||||
const cityClusterIdx = new Map<string, number>();
|
||||
|
||||
for (const city of cityList) {
|
||||
let bestIdx = -1;
|
||||
|
||||
if (clustered) {
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
const cl = clusters[i];
|
||||
const dx = city.x - cl.cx;
|
||||
const dy = city.y - cl.cy;
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
if (d < CLUSTER_RADIUS && d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx === -1) {
|
||||
clusters.push({
|
||||
cx: city.x, cy: city.y,
|
||||
lat: city.lat, lng: city.lng,
|
||||
totalVol: city.vol,
|
||||
emitted: city.emitted, received: city.received,
|
||||
cities: new Set([city.name]),
|
||||
});
|
||||
cityClusterIdx.set(city.name, clusters.length - 1);
|
||||
} else {
|
||||
const cl = clusters[bestIdx];
|
||||
const newVol = cl.totalVol + city.vol;
|
||||
cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol;
|
||||
cl.cy = (cl.cy * cl.totalVol + city.y * city.vol) / newVol;
|
||||
cl.totalVol = newVol;
|
||||
cl.emitted += city.emitted;
|
||||
cl.received += city.received;
|
||||
cl.cities.add(city.name);
|
||||
cityClusterIdx.set(city.name, bestIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Agréger les corridors en arcs inter-clusters ---
|
||||
interface ClusterArc {
|
||||
fromIdx: number; toIdx: number;
|
||||
totalVolume: number; count: number;
|
||||
}
|
||||
const clArcMap = new Map<string, ClusterArc>();
|
||||
for (const c of corridors) {
|
||||
const fi = cityClusterIdx.get(c.fromCity);
|
||||
const ti = cityClusterIdx.get(c.toCity);
|
||||
if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré
|
||||
const key = `${fi}||${ti}`;
|
||||
if (!clArcMap.has(key)) clArcMap.set(key, { fromIdx: fi, toIdx: ti, totalVolume: 0, count: 0 });
|
||||
const ca = clArcMap.get(key)!;
|
||||
ca.totalVolume += c.totalVolume;
|
||||
ca.count += c.count;
|
||||
}
|
||||
const clusterArcs = [...clArcMap.values()].sort((a, b) => b.totalVolume - a.totalVolume);
|
||||
|
||||
// --- 4. Couleur de balance par cluster ---
|
||||
const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1);
|
||||
const clusterColors = clusters.map(cl => {
|
||||
const net = cl.received - cl.emitted;
|
||||
const t = net / maxAbsNet;
|
||||
if (Math.abs(t) < NEUTRAL_THRESHOLD) return COLOR_NEUTRAL;
|
||||
return t < 0
|
||||
? lerpColor(COLOR_NEUTRAL, COLOR_NEG, -t)
|
||||
: lerpColor(COLOR_NEUTRAL, COLOR_POS, t);
|
||||
});
|
||||
|
||||
// Cluster de la ville focus
|
||||
const focusClusterIdx = focusCity !== null ? (cityClusterIdx.get(focusCity) ?? -1) : -1;
|
||||
|
||||
// --- 5. Éléments SVG des arcs ---
|
||||
const maxVol = Math.max(...clusterArcs.map(a => a.totalVolume), 1);
|
||||
const arcElems = clusterArcs.map((ca, idx) => {
|
||||
const p1 = { x: clusters[ca.fromIdx].cx, y: clusters[ca.fromIdx].cy };
|
||||
const p2 = { x: clusters[ca.toIdx].cx, y: clusters[ca.toIdx].cy };
|
||||
|
||||
const mx = (p1.x + p2.x) / 2;
|
||||
const my = (p1.y + p2.y) / 2;
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const CURVE = 0.28;
|
||||
const cx = mx - dy * CURVE;
|
||||
const cy = my + dx * CURVE;
|
||||
|
||||
const t = 0.5;
|
||||
const ax = (1-t)*(1-t)*p1.x + 2*(1-t)*t*cx + t*t*p2.x;
|
||||
const ay = (1-t)*(1-t)*p1.y + 2*(1-t)*t*cy + t*t*p2.y;
|
||||
const tln = Math.sqrt(dx*dx + dy*dy) || 1;
|
||||
const nx = dx / tln; const ny = dy / tln;
|
||||
const px = -ny; const py = nx;
|
||||
const AR = 5;
|
||||
const arrowPts = [
|
||||
`${ax + nx*AR},${ay + ny*AR}`,
|
||||
`${ax - nx*AR*0.6 + px*AR*0.5},${ay - ny*AR*0.6 + py*AR*0.5}`,
|
||||
`${ax - nx*AR*0.6 - px*AR*0.5},${ay - ny*AR*0.6 - py*AR*0.5}`,
|
||||
].join(' ');
|
||||
|
||||
const ratio = ca.totalVolume / maxVol;
|
||||
const strokeW = Math.max(1, 1.5 + Math.log1p(ca.totalVolume) * 0.8);
|
||||
const opacity = 0.35 + 0.55 * ratio;
|
||||
|
||||
const isFocusFrom = focusClusterIdx !== -1 && ca.fromIdx === focusClusterIdx;
|
||||
const isFocusTo = focusClusterIdx !== -1 && ca.toIdx === focusClusterIdx;
|
||||
const stroke = focusClusterIdx === -1 ? `url(#grad${idx})`
|
||||
: isFocusFrom ? '#ff8f00'
|
||||
: isFocusTo ? '#00acc1'
|
||||
: '#2e2f3a';
|
||||
const arrowFill = focusClusterIdx === -1 ? '#e65100'
|
||||
: isFocusFrom ? '#ff8f00'
|
||||
: isFocusTo ? '#00acc1'
|
||||
: '#2e2f3a';
|
||||
|
||||
return {
|
||||
idx, ca, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
|
||||
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`,
|
||||
};
|
||||
});
|
||||
|
||||
// --- 6. Éléments SVG des nœuds de clusters ---
|
||||
const maxClVol = Math.max(...clusters.map(cl => cl.totalVol), 1);
|
||||
const nodeElems = clusters.map((cl, idx) => {
|
||||
const r = Math.max(4, Math.min(18, 4 + 11 * (cl.totalVol / maxClVol)));
|
||||
const fillColor = clusterColors[idx];
|
||||
const isSelected = focusClusterIdx === idx;
|
||||
const cityCount = cl.cities.size;
|
||||
// Nom affiché : ville principale (la première dans l'itération = la plus volumineuse)
|
||||
const label = cityCount > 1 ? `+${cityCount}` : [...cl.cities][0];
|
||||
return { idx, cl, r, fillColor, isSelected, cityCount, label };
|
||||
});
|
||||
|
||||
return { arcElems, nodeElems };
|
||||
// tick en dep pour re-projeter sur pan/zoom
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [corridors, cityNodes, focusCity, tick, mapReady, clustered]);
|
||||
|
||||
const [popupIdx, setPopupIdx] = useState<number | null>(null);
|
||||
|
||||
// Ferme le popup sur déplacement/zoom
|
||||
useEffect(() => { setPopupIdx(null); }, [tick]);
|
||||
|
||||
// Handler de clic : ouvre/ferme le popup + focus
|
||||
const handleNodeClick = (nodeIdx: number) => {
|
||||
if (!svgElements) return;
|
||||
const node = svgElements.nodeElems[nodeIdx];
|
||||
const firstCity = [...node.cl.cities][0];
|
||||
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
|
||||
onCityClick(isCurrentFocus ? null : firstCity);
|
||||
setPopupIdx(popupIdx === nodeIdx ? null : nodeIdx);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
|
||||
{mapReady && svgElements && (
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ zIndex: 500, pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Dégradés or→orange pour les arcs (aucune ville sélectionnée) */}
|
||||
<defs>
|
||||
{svgElements.arcElems.map(a => (
|
||||
<linearGradient
|
||||
key={`grad${a.idx}`}
|
||||
id={`grad${a.idx}`}
|
||||
x1={a.p1.x} y1={a.p1.y}
|
||||
x2={a.p2.x} y2={a.p2.y}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#d4a843" />
|
||||
<stop offset="100%" stopColor="#e65100" />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{/* Arcs bezier */}
|
||||
{svgElements.arcElems.map(a => (
|
||||
<g key={`${a.ca.fromIdx}-${a.ca.toIdx}`} opacity={a.opacity}>
|
||||
<path
|
||||
d={a.path}
|
||||
fill="none"
|
||||
stroke={a.stroke}
|
||||
strokeWidth={a.strokeW}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<polygon points={a.arrowPts} fill={a.arrowFill} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Nœuds de clusters (pointer-events activés uniquement ici) */}
|
||||
<g style={{ pointerEvents: 'all' }}>
|
||||
{svgElements.nodeElems.map(node => (
|
||||
<g key={node.idx} onClick={() => handleNodeClick(node.idx)} style={{ cursor: 'pointer' }}>
|
||||
<circle
|
||||
cx={node.cl.cx}
|
||||
cy={node.cl.cy}
|
||||
r={node.r}
|
||||
fill={node.isSelected ? '#ffffff' : node.fillColor}
|
||||
stroke={node.isSelected ? node.fillColor : '#0a0b0f'}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{node.cityCount > 1 && (
|
||||
<text
|
||||
x={node.cl.cx}
|
||||
y={node.cl.cy + 3.5}
|
||||
textAnchor="middle"
|
||||
fontSize={node.r > 9 ? 9 : 7}
|
||||
fontWeight="bold"
|
||||
fill={node.isSelected ? node.fillColor : '#0a0b0f'}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Bouton cluster / villes */}
|
||||
<button
|
||||
onClick={() => setClustered(c => !c)}
|
||||
className={`absolute bottom-44 sm:bottom-24 left-4 z-[1002] px-3 py-1.5 rounded-lg text-xs font-medium border transition-all duration-200 ${
|
||||
clustered
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] border-[#d4a843] shadow-[0_0_10px_rgba(212,168,67,0.35)]'
|
||||
: 'bg-[#0a0b0f]/90 text-[#6b7280] border-[#2e2f3a] hover:text-[#d4a843] hover:border-[#d4a843]'
|
||||
}`}
|
||||
>
|
||||
{clustered ? '⬡ Clusters' : '· Villes'}
|
||||
</button>
|
||||
|
||||
{/* Popup cluster */}
|
||||
{mapReady && svgElements && popupIdx !== null && (() => {
|
||||
const node = svgElements.nodeElems[popupIdx];
|
||||
const cities = [...node.cl.cities].map(name => {
|
||||
const d = cityNodes.get(name);
|
||||
const net = d ? d.received - d.emitted : 0;
|
||||
return { name, net };
|
||||
}).sort((a, b) => Math.abs(b.net) - Math.abs(a.net));
|
||||
|
||||
// Position : décale à droite du cercle, recadre si hors écran
|
||||
const raw = node.cl.cx + node.r + 8;
|
||||
const containerW = containerRef.current?.clientWidth ?? 0;
|
||||
const popupW = 200;
|
||||
const left = raw + popupW > containerW ? node.cl.cx - node.r - 8 - popupW : raw;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[600] bg-[#0a0b0f]/95 border border-[#2e2f3a] rounded-xl p-3 shadow-xl"
|
||||
style={{ left, top: Math.max(4, node.cl.cy - 80), width: popupW, pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[#4b5563] text-[10px] uppercase tracking-widest">
|
||||
{node.cityCount > 1 ? `${node.cityCount} villes` : 'Ville'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPopupIdx(null)}
|
||||
className="text-[#4b5563] hover:text-white text-xs leading-none ml-2"
|
||||
>✕</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{cities.map(({ name, net }) => (
|
||||
<div key={name} className="flex items-center justify-between gap-1">
|
||||
<span className="text-white text-xs truncate">{name}</span>
|
||||
<span className={`text-xs font-mono shrink-0 ${net >= 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}>
|
||||
{net >= 0 ? '+' : ''}{Math.round(net).toLocaleString('fr-FR')} Ğ1
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,40 @@ export function HeatMap({ transactions }: HeatMapProps) {
|
||||
mapRef.current = map;
|
||||
heatRef.current = heat;
|
||||
|
||||
// Pendant zoom/pan : cache les overlays → le canvas live est visible directement.
|
||||
// Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné.
|
||||
const hideOverlays = () => {
|
||||
const prev = prevRef.current;
|
||||
const next = nextRef.current;
|
||||
if (prev) { prev.style.transition = 'none'; prev.style.opacity = '0'; }
|
||||
if (next) { next.style.transition = 'none'; next.style.opacity = '0'; }
|
||||
currentSrcRef.current = '';
|
||||
};
|
||||
|
||||
const syncAfterMove = () => {
|
||||
const canvas = (heat as unknown as { _canvas?: HTMLCanvasElement })._canvas;
|
||||
const next = nextRef.current;
|
||||
if (!canvas || !next) return;
|
||||
// Double RAF : leaflet.heat redessine en interne après l'événement
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const src = canvas.toDataURL();
|
||||
currentSrcRef.current = src;
|
||||
next.src = src;
|
||||
next.style.transition = 'none';
|
||||
next.style.opacity = '1';
|
||||
} catch { /* map torn down */ }
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
map.on('zoomstart movestart', hideOverlays);
|
||||
map.on('zoomend moveend', syncAfterMove);
|
||||
|
||||
return () => {
|
||||
map.off('zoomstart movestart', hideOverlays);
|
||||
map.off('zoomend moveend', syncAfterMove);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
heatRef.current = null;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
interface InfoPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function InfoPanel({ onClose }: InfoPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 z-[2000] bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modale */}
|
||||
<div className="fixed z-[2001] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[min(520px,calc(100vw-2rem))] max-h-[80vh] overflow-y-auto rounded-2xl bg-[#0f1016] border border-[#2e2f3a] shadow-2xl">
|
||||
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[#2e2f3a] sticky top-0 bg-[#0f1016] z-10">
|
||||
<div>
|
||||
<h2 className="text-[#d4a843] font-semibold text-base">Ğ1Flux</h2>
|
||||
<p className="text-[#6b7280] text-xs mt-0.5">Explorateur de transactions Ğ1v2</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[#4b5563] hover:text-white text-xl leading-none p-1"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Contenu */}
|
||||
<div className="px-5 py-4 flex flex-col gap-5 text-sm">
|
||||
|
||||
<Section title="Vues cartographiques">
|
||||
<Feature icon="🌡" name="Heatmap">
|
||||
Densité des transactions géolocalisées. Les zones chaudes concentrent le plus d'activité.
|
||||
Basculer avec le bouton <Kbd>Heatmap / Flux</Kbd>.
|
||||
</Feature>
|
||||
<Feature icon="⟿" name="Flux">
|
||||
Arcs entre villes représentant les flux de Ğ1. L'épaisseur indique le volume,
|
||||
la couleur la direction dominante.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Clusters & villes (vue Flux)">
|
||||
<Feature icon="⬡" name="Mode Clusters">
|
||||
Les villes géographiquement proches sont regroupées en un nœud unique.
|
||||
Le chiffre affiché indique le nombre de villes dans le groupe.
|
||||
</Feature>
|
||||
<Feature icon="·" name="Mode Villes">
|
||||
Chaque ville est affichée individuellement, sans regroupement.
|
||||
Basculer avec le bouton <Kbd>⬡ Clusters / · Villes</Kbd> (bas gauche de la carte).
|
||||
</Feature>
|
||||
<Feature icon="●" name="Couleur des nœuds">
|
||||
<span className="text-green-400">Vert</span> = receveur net (reçoit plus que ce qu'il émet) ·{' '}
|
||||
<span className="text-[#d4a843]">Or</span> = équilibré (dégradé or → vert selon l'excédent reçu) ·{' '}
|
||||
<span className="text-[#e53935]">Rouge</span> = émetteur net (dégradé or → rouge selon l'excédent émis).
|
||||
</Feature>
|
||||
<Feature icon="↗" name="Clic sur un nœud">
|
||||
Affiche la liste des villes du cluster avec leur balance individuelle,
|
||||
triée par valeur absolue.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Période">
|
||||
<Feature icon="📅" name="Préréglages">
|
||||
<Kbd>24h</Kbd> <Kbd>7 jours</Kbd> <Kbd>30 jours</Kbd> — fenêtre glissante jusqu'à maintenant.
|
||||
</Feature>
|
||||
<Feature icon="✎" name="Personnaliser">
|
||||
Saisir une durée de 1 à 365 jours.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Animation">
|
||||
<Feature icon="▶" name="Animer">
|
||||
Rejoue les transactions frame par frame sur la période sélectionnée
|
||||
(une frame = une journée).
|
||||
</Feature>
|
||||
<Feature icon="⏩" name="Contrôles">
|
||||
Lecture / pause · Navigation frame par frame (<Kbd>◀◀</Kbd> <Kbd>▶▶</Kbd>) ·
|
||||
Vitesse <Kbd>×1</Kbd> <Kbd>×2</Kbd> <Kbd>×4</Kbd>.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Statistiques">
|
||||
<Feature icon="📊" name="Panneau latéral">
|
||||
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.
|
||||
</Feature>
|
||||
<Feature icon="☰" name="Mobile">
|
||||
Le panneau est accessible via le bouton <Kbd>☰</Kbd> en haut à gauche.
|
||||
</Feature>
|
||||
<Feature icon="%" name="% Tx géoloc.">
|
||||
Pourcentage des transactions ayant une géolocalisation connue sur la période / frame courante.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
<Section title="Source de données">
|
||||
<Feature icon="●" name="Live Ğ1v2">
|
||||
Données temps réel de la blockchain Ğ1v2, actualisées toutes les 30 secondes.
|
||||
</Feature>
|
||||
</Section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-[#d4a843] text-xs font-semibold uppercase tracking-wider mb-2">{title}</h3>
|
||||
<div className="flex flex-col gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Feature({ icon, name, children }: { icon: string; name: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-[#4b5563] w-5 shrink-0 text-center leading-5 mt-0.5">{icon}</span>
|
||||
<div>
|
||||
<span className="text-white font-medium">{name}</span>
|
||||
<span className="text-[#6b7280]"> — </span>
|
||||
<span className="text-[#9ca3af]">{children}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-block bg-[#1a1b23] border border-[#2e2f3a] rounded px-1 py-0.5 text-[11px] text-[#d4a843] font-mono leading-none">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ interface PeriodSelectorProps {
|
||||
onChange: (days: number) => void;
|
||||
animationActive: boolean;
|
||||
onAnimate: () => void;
|
||||
viewMode: 'heatmap' | 'flow';
|
||||
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
||||
geoPercent?: number | null;
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
@@ -15,7 +18,7 @@ const PERIODS = [
|
||||
|
||||
const PRESET_DAYS = new Set([1, 7, 30]);
|
||||
|
||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate }: PeriodSelectorProps) {
|
||||
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [inputVal, setInputVal] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -39,13 +42,13 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }:
|
||||
const isCustomActive = !PRESET_DAYS.has(value);
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
|
||||
<div className="flex flex-wrap gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center max-w-[calc(100vw-2rem)]">
|
||||
{PERIODS.map(({ label, days }) => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => { onChange(days); setCustomOpen(false); }}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${value === days && !customOpen
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
@@ -82,7 +85,7 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }:
|
||||
<button
|
||||
onClick={openCustom}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${isCustomActive
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
@@ -107,6 +110,27 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }:
|
||||
>
|
||||
▶ Animer
|
||||
</button>
|
||||
|
||||
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||
|
||||
<button
|
||||
onClick={() => onViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap')}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||
${viewMode === 'flow'
|
||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
|
||||
</button>
|
||||
{geoPercent != null && (
|
||||
<span className="text-[10px] font-mono text-white px-1 shrink-0">
|
||||
{geoPercent}% Tx géoloc.
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+212
-80
@@ -1,13 +1,19 @@
|
||||
import { useRef } from 'react';
|
||||
import type { PeriodStats } from '../services/DataService';
|
||||
import type { FlowStats } from '../data/arcData';
|
||||
|
||||
interface StatsPanelProps {
|
||||
stats: PeriodStats | null;
|
||||
loading: boolean;
|
||||
periodDays: number;
|
||||
source: 'live' | 'mock';
|
||||
className?: string;
|
||||
currentUD: number;
|
||||
animationLabel?: string;
|
||||
viewMode?: 'heatmap' | 'flow';
|
||||
flowStats?: FlowStats | null;
|
||||
focusCity?: string | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||
@@ -33,7 +39,28 @@ function formatDU(g1: number, ud: number): string {
|
||||
return `≈ ${Math.round(du).toLocaleString('fr-FR')} DU`;
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel }: StatsPanelProps) {
|
||||
function CityRow({ city, volume, count, countryCode, accent }: {
|
||||
city: string; volume: number; count: number; countryCode: string; accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-white text-xs font-medium truncate">
|
||||
{countryCode && (
|
||||
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none shrink-0">
|
||||
{countryCode}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{city}</span>
|
||||
</span>
|
||||
<span className={`text-xs font-mono shrink-0 ml-1 ${accent ?? 'text-[#d4a843]'}`}>
|
||||
{volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
|
||||
<span className="text-[#4b5563] ml-0.5">· {count}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className }: StatsPanelProps) {
|
||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||
const prevStats = useRef<PeriodStats | null>(null);
|
||||
|
||||
@@ -57,16 +84,28 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
if (stats && !loading) prevStats.current = stats;
|
||||
|
||||
return (
|
||||
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
|
||||
<aside className={`flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto ${className ?? 'w-72 shrink-0'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
|
||||
Ğ
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white font-bold text-lg leading-none">Ğ1Flux</h1>
|
||||
<h1 className="text-white font-bold text-lg leading-none">
|
||||
Ğ1Flux
|
||||
<span className="text-[#4b5563] text-xs font-normal ml-1.5">v{__APP_VERSION__}</span>
|
||||
</h1>
|
||||
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto text-[#4b5563] hover:text-white transition-colors p-1 text-lg leading-none"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
@@ -82,84 +121,177 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-3">
|
||||
<StatCard
|
||||
label="Volume total"
|
||||
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
|
||||
sub={formatDU(stats.totalVolume, currentUD)}
|
||||
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
|
||||
/>
|
||||
<StatCard
|
||||
label="Transactions"
|
||||
value={stats.transactionCount.toLocaleString('fr-FR')}
|
||||
sub={(() => {
|
||||
const avg = stats.totalVolume / (stats.transactionCount || 1);
|
||||
return `≈ ${avg.toFixed(2)} Ğ1 / tx · ${formatDU(avg, currentUD)} / tx`;
|
||||
})()}
|
||||
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
|
||||
/>
|
||||
{/* Couverture géo — transactionCount inclut le total réel de la frame */}
|
||||
{source === 'live' && stats.transactionCount > 0 && (() => {
|
||||
const pct = Math.round((stats.geoCount / stats.transactionCount) * 100);
|
||||
return (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
|
||||
<p className="text-[#6b7280] text-xs">{stats.geoCount} / {stats.transactionCount}</p>
|
||||
</div>
|
||||
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Top cities */}
|
||||
{!loading && stats && stats.topCities.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest border-t border-[#1e1f2a] pt-3">
|
||||
Top villes
|
||||
</p>
|
||||
{stats.topCities.map((city, i) => (
|
||||
<div
|
||||
key={city.name}
|
||||
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex gap-2.5"
|
||||
>
|
||||
<span className="text-base shrink-0 mt-0.5">{MEDALS[i]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xs font-medium leading-snug">{city.name}</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
|
||||
{city.countryCode && (
|
||||
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
|
||||
{city.countryCode}
|
||||
</span>
|
||||
)}
|
||||
{city.count} tx
|
||||
</span>
|
||||
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
|
||||
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
|
||||
{delta(city.volume, prevCityVolume, city.name)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* ---- Vue HEATMAP ---- */}
|
||||
{viewMode === 'heatmap' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-3">
|
||||
<StatCard
|
||||
label="Volume total"
|
||||
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
|
||||
sub={formatDU(stats.totalVolume, currentUD)}
|
||||
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
|
||||
/>
|
||||
<StatCard
|
||||
label="Transactions"
|
||||
value={stats.transactionCount.toLocaleString('fr-FR')}
|
||||
sub={(() => {
|
||||
const avg = stats.totalVolume / (stats.transactionCount || 1);
|
||||
return `≈ ${avg.toFixed(2)} Ğ1 / tx · ${formatDU(avg, currentUD)} / tx`;
|
||||
})()}
|
||||
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
|
||||
/>
|
||||
{/* Couverture géo — transactionCount inclut le total réel de la frame */}
|
||||
{source === 'live' && stats.transactionCount > 0 && (() => {
|
||||
const pct = Math.round((stats.geoCount / stats.transactionCount) * 100);
|
||||
return (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
|
||||
<p className="text-[#6b7280] text-xs">{stats.geoCount} / {stats.transactionCount}</p>
|
||||
</div>
|
||||
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Top cities */}
|
||||
{!loading && stats && stats.topCities.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest border-t border-[#1e1f2a] pt-3">
|
||||
Top villes
|
||||
</p>
|
||||
{stats.topCities.map((city, i) => (
|
||||
<div
|
||||
key={city.name}
|
||||
className="bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-3 py-2.5 flex gap-2.5"
|
||||
>
|
||||
<span className="text-base shrink-0 mt-0.5">{MEDALS[i]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xs font-medium leading-snug">{city.name}</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="flex items-center gap-1.5 text-[#6b7280] text-xs">
|
||||
{city.countryCode && (
|
||||
<span className="text-[10px] font-bold bg-[#1e1f2a] text-[#6b7280] rounded px-1 py-0.5 leading-none">
|
||||
{city.countryCode}
|
||||
</span>
|
||||
)}
|
||||
{city.count} tx
|
||||
</span>
|
||||
<span className="text-[#d4a843] text-xs font-mono flex items-center gap-1">
|
||||
{city.volume.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} Ğ1
|
||||
{delta(city.volume, prevCityVolume, city.name)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- Vue FLUX ---- */}
|
||||
{viewMode === 'flow' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-16 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : flowStats ? (
|
||||
<div className="space-y-3">
|
||||
<StatCard
|
||||
label="Volume des flux"
|
||||
value={`${flowStats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
|
||||
sub={formatDU(flowStats.totalVolume, currentUD)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Arcs géolocalisés"
|
||||
value={flowStats.arcCount.toLocaleString('fr-FR')}
|
||||
sub={flowStats.arcCount > 0
|
||||
? `≈ ${(flowStats.totalVolume / flowStats.arcCount).toFixed(2)} Ğ1 / arc`
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
{/* Top émetteurs */}
|
||||
{flowStats.topEmitters.length > 0 && (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Top émetteurs</p>
|
||||
{flowStats.topEmitters.map((c, i) => (
|
||||
<div key={c.city} className="flex items-center gap-2">
|
||||
<span className="text-sm shrink-0">{MEDALS[i]}</span>
|
||||
<CityRow city={c.city} volume={c.volume} count={c.count} countryCode={c.countryCode} accent="text-[#ff8f00]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top récepteurs */}
|
||||
{flowStats.topReceivers.length > 0 && (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-2">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Top récepteurs</p>
|
||||
{flowStats.topReceivers.map((c, i) => (
|
||||
<div key={c.city} className="flex items-center gap-2">
|
||||
<span className="text-sm shrink-0">{MEDALS[i]}</span>
|
||||
<CityRow city={c.city} volume={c.volume} count={c.count} countryCode={c.countryCode} accent="text-[#00acc1]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance nette */}
|
||||
{flowStats.netBalance.length > 0 && (
|
||||
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
|
||||
<p className="text-[10px] text-[#4b5563] flex items-center gap-1.5">
|
||||
<span style={{ color: '#ff6d00' }}>●</span>émetteur
|
||||
<span style={{ color: '#00c853' }}>●</span>récepteur
|
||||
</p>
|
||||
</div>
|
||||
{flowStats.netBalance.map((c) => (
|
||||
<div key={c.city} className="flex items-center justify-between">
|
||||
<span className="text-white text-xs truncate">{c.city}</span>
|
||||
<span className={`text-xs font-mono shrink-0 ml-2 ${c.net >= 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}>
|
||||
{c.net >= 0 ? '+' : ''}{Math.round(c.net).toLocaleString('fr-FR')} Ğ1
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ville focus */}
|
||||
{focusCity && (
|
||||
<div className="bg-[#0f1016] border border-[#d4a843]/30 rounded-xl p-3">
|
||||
<p className="text-[#4b5563] text-xs uppercase tracking-widest mb-1">Ville sélectionnée</p>
|
||||
<p className="text-[#d4a843] text-sm font-medium">{focusCity}</p>
|
||||
<p className="text-[#4b5563] text-xs mt-0.5">
|
||||
<span className="text-[#ff8f00]">■</span> sortants
|
||||
<span className="text-[#00acc1]">■</span> entrants
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[#4b5563] text-xs">Aucun arc à afficher.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Transaction } from './mockData';
|
||||
|
||||
export interface TransactionArc {
|
||||
id: string;
|
||||
timestamp: number; // Unix ms
|
||||
amount: number; // Ğ1
|
||||
fromLat: number;
|
||||
fromLng: number;
|
||||
fromCity: string;
|
||||
fromCountry: string;
|
||||
fromKey: string;
|
||||
toLat: number;
|
||||
toLng: number;
|
||||
toCity: string;
|
||||
toCountry: string;
|
||||
toKey: string;
|
||||
}
|
||||
|
||||
/** Corridor agrégé par paire de villes (fromCity → toCity). */
|
||||
export interface Corridor {
|
||||
fromCity: string;
|
||||
fromLat: number;
|
||||
fromLng: number;
|
||||
fromCountry: string;
|
||||
toCity: string;
|
||||
toLat: number;
|
||||
toLng: number;
|
||||
toCountry: string;
|
||||
totalVolume: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface FlowStats {
|
||||
totalVolume: number;
|
||||
arcCount: number;
|
||||
topEmitters: { city: string; volume: number; count: number; countryCode: string }[];
|
||||
topReceivers: { city: string; volume: number; count: number; countryCode: string }[];
|
||||
netBalance: { city: string; net: number; countryCode: string }[];
|
||||
}
|
||||
|
||||
/** Agrège les arcs individuels en corridors ville→ville, triés par volume. */
|
||||
export function buildCorridors(arcs: TransactionArc[]): Corridor[] {
|
||||
const map = new Map<string, Corridor>();
|
||||
for (const arc of arcs) {
|
||||
const key = `${arc.fromCity}||${arc.toCity}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
fromCity: arc.fromCity, fromLat: arc.fromLat, fromLng: arc.fromLng, fromCountry: arc.fromCountry,
|
||||
toCity: arc.toCity, toLat: arc.toLat, toLng: arc.toLng, toCountry: arc.toCountry,
|
||||
totalVolume: 0, count: 0,
|
||||
});
|
||||
}
|
||||
const c = map.get(key)!;
|
||||
c.totalVolume += arc.amount;
|
||||
c.count++;
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalVolume - a.totalVolume);
|
||||
}
|
||||
|
||||
export function computeFlowStats(arcs: TransactionArc[]): FlowStats {
|
||||
const emitters = new Map<string, { volume: number; count: number; country: string }>();
|
||||
const receivers = new Map<string, { volume: number; count: number; country: string }>();
|
||||
|
||||
for (const arc of arcs) {
|
||||
if (!emitters.has(arc.fromCity)) emitters.set(arc.fromCity, { volume: 0, count: 0, country: arc.fromCountry });
|
||||
if (!receivers.has(arc.toCity)) receivers.set(arc.toCity, { volume: 0, count: 0, country: arc.toCountry });
|
||||
emitters.get(arc.fromCity)!.volume += arc.amount;
|
||||
emitters.get(arc.fromCity)!.count++;
|
||||
receivers.get(arc.toCity)!.volume += arc.amount;
|
||||
receivers.get(arc.toCity)!.count++;
|
||||
}
|
||||
|
||||
const allCities = new Set([...emitters.keys(), ...receivers.keys()]);
|
||||
const netBalance = [...allCities].map(city => ({
|
||||
city,
|
||||
net: (receivers.get(city)?.volume ?? 0) - (emitters.get(city)?.volume ?? 0),
|
||||
countryCode: emitters.get(city)?.country ?? receivers.get(city)?.country ?? '',
|
||||
})).sort((a, b) => Math.abs(b.net) - Math.abs(a.net)).slice(0, 5);
|
||||
|
||||
return {
|
||||
totalVolume: arcs.reduce((s, a) => s + a.amount, 0),
|
||||
arcCount: arcs.length,
|
||||
topEmitters: [...emitters.entries()].sort((a, b) => b[1].volume - a[1].volume).slice(0, 3)
|
||||
.map(([city, d]) => ({ city, volume: d.volume, count: d.count, countryCode: d.country })),
|
||||
topReceivers: [...receivers.entries()].sort((a, b) => b[1].volume - a[1].volume).slice(0, 3)
|
||||
.map(([city, d]) => ({ city, volume: d.volume, count: d.count, countryCode: d.country })),
|
||||
netBalance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère des arcs mock : chaque transaction devient un arc dont le destinataire
|
||||
* est une transaction aléatoire d'une ville différente.
|
||||
*/
|
||||
export function buildMockArcs(transactions: Transaction[]): TransactionArc[] {
|
||||
if (transactions.length < 2) return [];
|
||||
const arcs: TransactionArc[] = [];
|
||||
for (let i = 0; i < transactions.length; i++) {
|
||||
if (Math.random() > 0.55) continue; // ~55 % de couverture
|
||||
const from = transactions[i];
|
||||
let toIdx = Math.floor(Math.random() * transactions.length);
|
||||
for (let tries = 0; tries < 8 && transactions[toIdx].city === from.city; tries++) {
|
||||
toIdx = Math.floor(Math.random() * transactions.length);
|
||||
}
|
||||
const to = transactions[toIdx];
|
||||
if (to.city === from.city) continue;
|
||||
arcs.push({
|
||||
id: `${from.id}-arc`,
|
||||
timestamp: from.timestamp,
|
||||
amount: from.amount,
|
||||
fromLat: from.lat, fromLng: from.lng,
|
||||
fromCity: from.city, fromCountry: from.countryCode,
|
||||
fromKey: from.fromKey,
|
||||
toLat: to.lat, toLng: to.lng,
|
||||
toCity: to.city, toCountry: to.countryCode,
|
||||
toKey: to.toKey,
|
||||
});
|
||||
}
|
||||
return arcs;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import type { Transaction } from '../data/mockData';
|
||||
import type { TransactionArc } from '../data/arcData';
|
||||
|
||||
export interface TimeFrame {
|
||||
label: string;
|
||||
@@ -56,7 +57,7 @@ function buildFrames(periodDays: number): TimeFrame[] {
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function useAnimation(transactions: Transaction[], periodDays: number, allTimestamps: number[] = []) {
|
||||
export function useAnimation(transactions: Transaction[], arcs: TransactionArc[], periodDays: number, allTimestamps: number[] = []) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -95,6 +96,13 @@ export function useAnimation(transactions: Transaction[], periodDays: number, al
|
||||
return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
|
||||
}, [active, transactions, frames, currentIndex]);
|
||||
|
||||
const visibleArcs = useMemo(() => {
|
||||
if (!active || frames.length === 0) return arcs;
|
||||
const frame = frames[currentIndex];
|
||||
if (!frame) return arcs;
|
||||
return arcs.filter((a) => a.timestamp >= frame.from && a.timestamp < frame.to);
|
||||
}, [active, arcs, frames, currentIndex]);
|
||||
|
||||
// Nombre total de transfers (géo + non-géo) dans la frame courante
|
||||
const frameTotalCount = useMemo(() => {
|
||||
if (!active || frames.length === 0 || allTimestamps.length === 0) return null;
|
||||
@@ -117,6 +125,7 @@ export function useAnimation(transactions: Transaction[], periodDays: number, al
|
||||
frames,
|
||||
currentFrame: frames[currentIndex] ?? null,
|
||||
visibleTransactions,
|
||||
visibleArcs,
|
||||
frameTotalCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
+67
-27
@@ -12,13 +12,17 @@
|
||||
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
|
||||
*/
|
||||
|
||||
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
|
||||
import { resolveGeoByKeys } from './adapters/CesiumAdapter';
|
||||
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey } from './adapters/SubsquidAdapter';
|
||||
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter';
|
||||
import {
|
||||
getTransactionsForPeriod,
|
||||
computeStats,
|
||||
type Transaction,
|
||||
} from '../data/mockData';
|
||||
import {
|
||||
buildMockArcs,
|
||||
type TransactionArc,
|
||||
} from '../data/arcData';
|
||||
|
||||
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
|
||||
|
||||
@@ -43,15 +47,16 @@ async function getIdentityKeyMap(): Promise<Map<string, string>> {
|
||||
}
|
||||
|
||||
async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
geolocated: Transaction[];
|
||||
totalCount: number;
|
||||
totalVolume: number;
|
||||
geolocated: Transaction[];
|
||||
arcs: TransactionArc[];
|
||||
totalCount: number;
|
||||
totalVolume: number;
|
||||
allTimestamps: number[];
|
||||
}> {
|
||||
// ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
|
||||
const limit = Math.max(2000, Math.ceil(periodDays * 600));
|
||||
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit);
|
||||
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
|
||||
if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
|
||||
|
||||
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
|
||||
|
||||
@@ -63,15 +68,21 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
console.warn('Identity key map indisponible :', err);
|
||||
}
|
||||
|
||||
// Clés Duniter uniques des émetteurs
|
||||
const duniterKeys = [...new Set(
|
||||
rawTransfers.map((t) => keyMap.get(t.fromId)).filter(Boolean) as string[]
|
||||
)];
|
||||
// Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+)
|
||||
// Pour les membres WoT : via keyMap (genesis key = _id Cesium+)
|
||||
// Pour les non-membres : conversion directe SS58 → Duniter key
|
||||
const resolveKey = (ss58: string): string =>
|
||||
keyMap.get(ss58) ?? ss58ToDuniterKey(ss58);
|
||||
|
||||
const allDuniterKeys = [...new Set([
|
||||
...rawTransfers.map((t) => t.fromId ? resolveKey(t.fromId) : undefined),
|
||||
...rawTransfers.map((t) => t.toId ? resolveKey(t.toId) : undefined),
|
||||
].filter(Boolean) as string[])];
|
||||
|
||||
// Résolution géo par clé Duniter (_id Cesium+)
|
||||
let geoMap = new Map<string, { lat: number; lng: number; city: string; countryCode: string }>();
|
||||
try {
|
||||
const profiles = await resolveGeoByKeys(duniterKeys);
|
||||
const profiles = await resolveGeoByKeys(allDuniterKeys);
|
||||
for (const [key, p] of profiles) {
|
||||
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode });
|
||||
}
|
||||
@@ -79,28 +90,53 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||
console.warn('Cesium+ indisponible :', err);
|
||||
}
|
||||
|
||||
// Seules les transactions avec un profil géo entrent dans le heatmap
|
||||
const geolocated: Transaction[] = [];
|
||||
for (const t of rawTransfers) {
|
||||
const duniterKey = keyMap.get(t.fromId);
|
||||
if (!duniterKey) continue;
|
||||
const geo = geoMap.get(duniterKey);
|
||||
if (!geo) continue;
|
||||
const geolocated: Transaction[] = [];
|
||||
const arcs: TransactionArc[] = [];
|
||||
|
||||
for (const t of rawTransfers) {
|
||||
const fromDuniterKey = t.fromId ? resolveKey(t.fromId) : undefined;
|
||||
if (!fromDuniterKey) continue;
|
||||
const fromGeo = geoMap.get(fromDuniterKey);
|
||||
if (!fromGeo) continue;
|
||||
|
||||
const fromCity = cleanCityName(fromGeo.city);
|
||||
|
||||
// Heatmap : émetteur géolocalisé
|
||||
geolocated.push({
|
||||
id: t.id,
|
||||
timestamp: t.timestamp,
|
||||
lat: geo.lat,
|
||||
lng: geo.lng,
|
||||
lat: fromGeo.lat,
|
||||
lng: fromGeo.lng,
|
||||
amount: t.amount,
|
||||
city: geo.city,
|
||||
countryCode: geo.countryCode,
|
||||
city: fromCity,
|
||||
countryCode: fromGeo.countryCode,
|
||||
fromKey: t.fromId,
|
||||
toKey: t.toId,
|
||||
});
|
||||
|
||||
// Arc : les deux extrémités géolocalisées + villes différentes
|
||||
const toDuniterKey = t.toId ? resolveKey(t.toId) : undefined;
|
||||
if (!toDuniterKey) continue;
|
||||
const toGeo = geoMap.get(toDuniterKey);
|
||||
if (!toGeo) continue;
|
||||
|
||||
const toCity = cleanCityName(toGeo.city);
|
||||
if (fromCity === toCity) continue;
|
||||
|
||||
arcs.push({
|
||||
id: `${t.id}-arc`,
|
||||
timestamp: t.timestamp,
|
||||
amount: t.amount,
|
||||
fromLat: fromGeo.lat, fromLng: fromGeo.lng,
|
||||
fromCity, fromCountry: fromGeo.countryCode,
|
||||
fromKey: t.fromId,
|
||||
toLat: toGeo.lat, toLng: toGeo.lng,
|
||||
toCity, toCountry: toGeo.countryCode,
|
||||
toKey: t.toId,
|
||||
});
|
||||
}
|
||||
|
||||
return { geolocated, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
|
||||
return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,11 +150,12 @@ export interface PeriodStats {
|
||||
}
|
||||
|
||||
export interface DataResult {
|
||||
transactions: Transaction[]; // uniquement géolocalisées → heatmap
|
||||
transactions: Transaction[]; // uniquement géolocalisées → heatmap
|
||||
arcs: TransactionArc[]; // les deux extrémités géolocalisées → vue flux
|
||||
stats: PeriodStats;
|
||||
source: 'live' | 'mock';
|
||||
currentUD: number; // valeur du DU courant en Ğ1
|
||||
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
|
||||
currentUD: number; // valeur du DU courant en Ğ1
|
||||
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo)
|
||||
}
|
||||
|
||||
export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
@@ -126,8 +163,10 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
const transactions = getTransactionsForPeriod(periodDays);
|
||||
const base = computeStats(transactions);
|
||||
const arcs = buildMockArcs(transactions);
|
||||
return {
|
||||
transactions,
|
||||
arcs,
|
||||
stats: { ...base, geoCount: transactions.length },
|
||||
source: 'mock',
|
||||
currentUD: 11.78,
|
||||
@@ -135,7 +174,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
};
|
||||
}
|
||||
|
||||
const [{ geolocated, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
|
||||
const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
|
||||
fetchLiveTransactions(periodDays),
|
||||
getCurrentUD(),
|
||||
]);
|
||||
@@ -143,6 +182,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||
|
||||
return {
|
||||
transactions: geolocated,
|
||||
arcs,
|
||||
stats: {
|
||||
totalVolume,
|
||||
transactionCount: totalCount,
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -1,8 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const { version } = JSON.parse(readFileSync('./package.json', 'utf-8')) as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
|
||||
Reference in New Issue
Block a user