Compare commits
36 Commits
ead63f9459
..
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 136571ed53 | |||
| b884884a04 | |||
| 97ff22027c | |||
| ab72d8218b | |||
| 57c1888346 | |||
| 7ee3b09f0f | |||
| b9bcfa8518 | |||
| 42286a8c0d | |||
| ee5e401185 | |||
| f29625c6bc | |||
| a2fdad46d4 | |||
| 45080d83ac | |||
| bea7cbe60f | |||
| bc61527b4e | |||
| ac2f5bc431 | |||
| 2debc3587a | |||
| 8a31b60716 | |||
| a9bf445747 | |||
| d7fef466f3 | |||
| 14d218e4ff | |||
| d50b30666b | |||
| 30057a07fb | |||
| 40c09e2e4b | |||
| 0aea929b48 | |||
| d4cc4fbd3a | |||
| bf2dbd6d35 | |||
| 2fce063703 | |||
| 3aa3933b4c | |||
| 2ed51243d2 | |||
| 7975abc619 | |||
| 21441c4550 | |||
| 26e429c8c0 | |||
| 03f63aec46 | |||
| ec06c4e35c | |||
| aa1f3d5f2f | |||
| 034e16ee37 |
@@ -27,3 +27,7 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
/docs-plan/
|
||||||
|
/docs-syoul/
|
||||||
|
/docs-bugs/
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "g1flux",
|
"name": "g1flux",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+91
-7
@@ -1,19 +1,42 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { StatsPanel } from './components/StatsPanel';
|
import { StatsPanel } from './components/StatsPanel';
|
||||||
import { PeriodSelector } from './components/PeriodSelector';
|
import { PeriodSelector } from './components/PeriodSelector';
|
||||||
import { HeatMap } from './components/HeatMap';
|
import { HeatMap } from './components/HeatMap';
|
||||||
|
import { FlowMap } from './components/FlowMap';
|
||||||
|
import { AnimationPlayer } from './components/AnimationPlayer';
|
||||||
import { fetchData } from './services/DataService';
|
import { fetchData } from './services/DataService';
|
||||||
import type { PeriodStats } from './services/DataService';
|
import type { PeriodStats } from './services/DataService';
|
||||||
import type { Transaction } from './data/mockData';
|
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';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [periodDays, setPeriodDays] = useState(7);
|
const [periodDays, setPeriodDays] = useState(7);
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
|
const [arcs, setArcs] = useState<TransactionArc[]>([]);
|
||||||
const [stats, setStats] = useState<PeriodStats | null>(null);
|
const [stats, setStats] = useState<PeriodStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
const [source, setSource] = useState<'live' | 'mock'>('mock');
|
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 animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||||
|
|
||||||
|
const handlePeriodChange = (days: number) => {
|
||||||
|
animation.deactivate();
|
||||||
|
setPeriodDays(days);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
|
||||||
|
setViewMode(mode);
|
||||||
|
setFocusCity(null);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -22,11 +45,14 @@ export default function App() {
|
|||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
else setRefreshing(true);
|
else setRefreshing(true);
|
||||||
fetchData(periodDays)
|
fetchData(periodDays)
|
||||||
.then(({ transactions, stats, source }) => {
|
.then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setTransactions(transactions);
|
setTransactions(transactions);
|
||||||
|
setArcs(arcs);
|
||||||
setStats(stats);
|
setStats(stats);
|
||||||
setSource(source);
|
setSource(source);
|
||||||
|
setCurrentUD(currentUD);
|
||||||
|
setAllTimestamps(allTimestamps);
|
||||||
setLastUpdate(new Date());
|
setLastUpdate(new Date());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -42,22 +68,65 @@ export default function App() {
|
|||||||
return () => { cancelled = true; clearInterval(interval); };
|
return () => { cancelled = true; clearInterval(interval); };
|
||||||
}, [periodDays]);
|
}, [periodDays]);
|
||||||
|
|
||||||
|
// Stats heatmap sur la fenêtre courante en mode animation
|
||||||
|
const visibleStats: PeriodStats | null = animation.active
|
||||||
|
? {
|
||||||
|
...computeStats(animation.visibleTransactions),
|
||||||
|
geoCount: animation.visibleTransactions.length,
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
||||||
{/* Side panel */}
|
{/* Side panel */}
|
||||||
<StatsPanel stats={stats} loading={loading} periodDays={periodDays} source={source} />
|
<StatsPanel
|
||||||
|
stats={visibleStats}
|
||||||
|
loading={loading}
|
||||||
|
periodDays={periodDays}
|
||||||
|
source={source}
|
||||||
|
currentUD={currentUD}
|
||||||
|
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
|
||||||
|
viewMode={viewMode}
|
||||||
|
flowStats={flowStats}
|
||||||
|
focusCity={focusCity}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Map area */}
|
{/* Map area */}
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
<HeatMap transactions={transactions} />
|
{viewMode === 'heatmap' ? (
|
||||||
|
<HeatMap transactions={animation.visibleTransactions} />
|
||||||
|
) : (
|
||||||
|
<FlowMap
|
||||||
|
arcs={animation.active ? animation.visibleArcs : arcs}
|
||||||
|
focusCity={focusCity}
|
||||||
|
onCityClick={setFocusCity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Period selector — floating over map */}
|
{/* Period selector — floating over map */}
|
||||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
|
||||||
<PeriodSelector value={periodDays} onChange={setPeriodDays} />
|
<PeriodSelector
|
||||||
|
value={periodDays}
|
||||||
|
onChange={handlePeriodChange}
|
||||||
|
animationActive={animation.active}
|
||||||
|
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={handleViewModeChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transaction count + source badge */}
|
{/* Transaction count + source badge (masqués en mode animation) */}
|
||||||
{!loading && (
|
{!loading && !animation.active && (
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
<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]">
|
<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
|
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
||||||
@@ -74,6 +143,21 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Animation player */}
|
||||||
|
{animation.active && (
|
||||||
|
<AnimationPlayer
|
||||||
|
frames={animation.frames}
|
||||||
|
currentIndex={animation.currentIndex}
|
||||||
|
playing={animation.playing}
|
||||||
|
speed={animation.speed}
|
||||||
|
onSeek={animation.seek}
|
||||||
|
onPlay={animation.play}
|
||||||
|
onPause={animation.pause}
|
||||||
|
onSpeedChange={animation.setSpeed}
|
||||||
|
onClose={animation.deactivate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Loading overlay */}
|
{/* Loading overlay */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
|
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { TimeFrame } from '../hooks/useAnimation';
|
||||||
|
|
||||||
|
interface AnimationPlayerProps {
|
||||||
|
frames: TimeFrame[];
|
||||||
|
currentIndex: number;
|
||||||
|
playing: boolean;
|
||||||
|
speed: 1 | 2 | 4;
|
||||||
|
onSeek: (i: number) => void;
|
||||||
|
onPlay: () => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onSpeedChange: (s: 1 | 2 | 4) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimationPlayer({
|
||||||
|
frames,
|
||||||
|
currentIndex,
|
||||||
|
playing,
|
||||||
|
speed,
|
||||||
|
onSeek,
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* Frame label + position */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[#d4a843] text-sm font-medium">
|
||||||
|
{frame?.label ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#4b5563] text-xs tabular-nums">
|
||||||
|
{currentIndex + 1} / {frames.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={frames.length - 1}
|
||||||
|
value={currentIndex}
|
||||||
|
onChange={(e) => onSeek(Number(e.target.value))}
|
||||||
|
className="w-full h-1 accent-[#d4a843] cursor-pointer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Controls row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
|
||||||
|
{/* Playback buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onSeek(Math.max(0, currentIndex - 1))}
|
||||||
|
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
|
||||||
|
title="Frame précédente"
|
||||||
|
>
|
||||||
|
◀◀
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={playing ? onPause : onPlay}
|
||||||
|
className="px-4 py-1.5 bg-[#d4a843] text-[#0a0b0f] rounded-lg font-bold text-sm hover:bg-[#e8c060] transition-colors min-w-[52px] text-center shadow-[0_0_10px_rgba(212,168,67,0.3)]"
|
||||||
|
>
|
||||||
|
{playing ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSeek(Math.min(frames.length - 1, currentIndex + 1))}
|
||||||
|
className="px-2.5 py-1.5 text-[#6b7280] hover:text-white transition-colors text-sm"
|
||||||
|
title="Frame suivante"
|
||||||
|
>
|
||||||
|
▶▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => onSpeedChange(s)}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
|
speed === s
|
||||||
|
? 'bg-[#d4a843] text-[#0a0b0f]'
|
||||||
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
×{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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,209 @@
|
|||||||
|
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
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); // incrémenté sur moveend/zoomend → re-render
|
||||||
|
|
||||||
|
// 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 (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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxVol = Math.max(...corridors.map(c => c.totalVolume), 1);
|
||||||
|
const maxNodeVol = Math.max(...[...cityNodes.values()].map(c => c.emitted + c.received), 1);
|
||||||
|
|
||||||
|
// ---- Arcs ----
|
||||||
|
const arcElems = corridors.map((c, idx) => {
|
||||||
|
const p1 = proj(c.fromLat, c.fromLng);
|
||||||
|
const p2 = proj(c.toLat, c.toLng);
|
||||||
|
|
||||||
|
// Point de contrôle bezier quadratique : décalage perpendiculaire au milieu
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Flèche de direction au milieu (t = 0.5) du bezier
|
||||||
|
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; // tangente normalisée
|
||||||
|
const px = -ny; const py = nx; // perpendiculaire
|
||||||
|
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 = c.totalVolume / maxVol;
|
||||||
|
const strokeW = Math.max(1, 1.5 + Math.log1p(c.totalVolume) * 0.8);
|
||||||
|
const opacity = 0.35 + 0.55 * ratio;
|
||||||
|
|
||||||
|
// Couleur selon focusCity
|
||||||
|
const isFocusFrom = focusCity && c.fromCity === focusCity;
|
||||||
|
const isFocusTo = focusCity && c.toCity === focusCity;
|
||||||
|
const stroke = !focusCity ? `url(#grad${idx})`
|
||||||
|
: isFocusFrom ? '#ff8f00'
|
||||||
|
: isFocusTo ? '#00acc1'
|
||||||
|
: '#2e2f3a';
|
||||||
|
const arrowFill = !focusCity ? '#e65100'
|
||||||
|
: isFocusFrom ? '#ff8f00'
|
||||||
|
: isFocusTo ? '#00acc1'
|
||||||
|
: '#2e2f3a';
|
||||||
|
|
||||||
|
return {
|
||||||
|
idx, c, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
|
||||||
|
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Nœuds ----
|
||||||
|
const nodeElems = [...cityNodes.entries()].map(([name, data]) => {
|
||||||
|
const p = proj(data.lat, data.lng);
|
||||||
|
const vol = data.emitted + data.received;
|
||||||
|
const r = Math.max(3, Math.min(14, 3 + 9 * (vol / maxNodeVol)));
|
||||||
|
return { name, p, r, isSelected: focusCity === name };
|
||||||
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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.c.fromCity}-${a.c.toCity}`} 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 villes (pointer-events activés uniquement ici) */}
|
||||||
|
<g style={{ pointerEvents: 'all' }}>
|
||||||
|
{svgElements.nodeElems.map(node => (
|
||||||
|
<circle
|
||||||
|
key={node.name}
|
||||||
|
cx={node.p.x}
|
||||||
|
cy={node.p.y}
|
||||||
|
r={node.r}
|
||||||
|
fill={node.isSelected ? '#ffffff' : '#d4a843'}
|
||||||
|
stroke={node.isSelected ? '#d4a843' : '#0a0b0f'}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => onCityClick(focusCity === node.name ? null : node.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+112
-10
@@ -33,6 +33,12 @@ export function HeatMap({ transactions }: 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);
|
||||||
|
// 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);
|
||||||
|
const nextRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
// Src of the currently visible frame (so prev can be initialised correctly)
|
||||||
|
const currentSrcRef = useRef<string>('');
|
||||||
|
|
||||||
// Initialize map once
|
// Initialize map once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,39 +63,135 @@ export function HeatMap({ transactions }: HeatMapProps) {
|
|||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
heatRef.current = heat;
|
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 () => {
|
return () => {
|
||||||
|
map.off('zoomstart movestart', hideOverlays);
|
||||||
|
map.off('zoomend moveend', syncAfterMove);
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
heatRef.current = null;
|
heatRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update heatmap data when transactions change
|
// Crossfade: two img overlays swap roles each frame.
|
||||||
|
// Canvas is never hidden — we only read its pixel data via toDataURL().
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!heatRef.current || !mapRef.current) return;
|
if (!heatRef.current || !mapRef.current) return;
|
||||||
|
|
||||||
// Normalize amounts for intensity (log scale feels better visually)
|
const canvas = (heatRef.current as unknown as { _canvas?: HTMLCanvasElement })._canvas;
|
||||||
const maxAmount = Math.max(...transactions.map((t) => t.amount), 1);
|
const prev = prevRef.current;
|
||||||
|
const next = nextRef.current;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const maxAmount = Math.max(...transactions.map((t) => t.amount), 1);
|
||||||
const points: L.HeatLatLngTuple[] = transactions.map((tx) => [
|
const points: L.HeatLatLngTuple[] = transactions.map((tx) => [
|
||||||
tx.lat,
|
tx.lat,
|
||||||
tx.lng,
|
tx.lng,
|
||||||
Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1),
|
Math.min(Math.log1p(tx.amount) / Math.log1p(maxAmount), 1),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Guard: only update if the heat layer is still attached to the map
|
|
||||||
try {
|
try {
|
||||||
heatRef.current.setLatLngs(points);
|
heatRef.current?.setLatLngs(points);
|
||||||
} catch {
|
} catch {
|
||||||
// map was torn down (React StrictMode double-invoke), ignore
|
// map was torn down (React StrictMode double-invoke), ignore
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canvas || !prev || !next) {
|
||||||
|
draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 1 (synchronous): set start state ---
|
||||||
|
// prev shows the current frame (or nothing on first run)
|
||||||
|
prev.src = currentSrcRef.current;
|
||||||
|
prev.style.transition = 'none';
|
||||||
|
prev.style.opacity = currentSrcRef.current ? '1' : '0';
|
||||||
|
|
||||||
|
// next is hidden and will receive the incoming frame
|
||||||
|
next.style.transition = 'none';
|
||||||
|
next.style.opacity = '0';
|
||||||
|
|
||||||
|
void prev.offsetWidth; // flush CSS so transitions start cleanly
|
||||||
|
|
||||||
|
// Ask leaflet to draw new data (schedules an internal RAF)
|
||||||
|
draw();
|
||||||
|
|
||||||
|
// --- Phase 2 (after leaflet redraws): capture new frame, start crossfade ---
|
||||||
|
// leaflet.heat schedules its own RAF inside draw() above.
|
||||||
|
// Our raf1 is queued *after* leaflet's RAF, so when raf1 fires,
|
||||||
|
// leaflet has already redrawn the canvas.
|
||||||
|
let raf2 = 0;
|
||||||
|
const raf1 = requestAnimationFrame(() => {
|
||||||
|
raf2 = requestAnimationFrame(() => {
|
||||||
|
let src: string;
|
||||||
|
try {
|
||||||
|
src = canvas.toDataURL();
|
||||||
|
} catch {
|
||||||
|
return; // map torn down
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSrcRef.current = src;
|
||||||
|
next.src = src;
|
||||||
|
void next.offsetWidth; // ensure img is decoded before transition
|
||||||
|
|
||||||
|
const DUR = '0.55s ease-in-out';
|
||||||
|
prev.style.transition = `opacity ${DUR}`;
|
||||||
|
prev.style.opacity = '0';
|
||||||
|
next.style.transition = `opacity ${DUR}`;
|
||||||
|
next.style.opacity = '1';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); };
|
||||||
}, [transactions]);
|
}, [transactions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
|
||||||
ref={containerRef}
|
<div ref={containerRef} className="absolute inset-0" />
|
||||||
className="w-full h-full"
|
{/* prev: outgoing frame */}
|
||||||
style={{ minHeight: 0 }}
|
<img
|
||||||
|
ref={prevRef}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
style={{ opacity: 0, zIndex: 500 }}
|
||||||
/>
|
/>
|
||||||
|
{/* next: incoming frame — sits on top of prev during crossfade */}
|
||||||
|
<img
|
||||||
|
ref={nextRef}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
style={{ opacity: 0, zIndex: 501 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
interface PeriodSelectorProps {
|
interface PeriodSelectorProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (days: number) => void;
|
onChange: (days: number) => void;
|
||||||
|
animationActive: boolean;
|
||||||
|
onAnimate: () => void;
|
||||||
|
viewMode: 'heatmap' | 'flow';
|
||||||
|
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
@@ -9,16 +15,40 @@ const PERIODS = [
|
|||||||
{ label: '30 jours', days: 30 },
|
{ label: '30 jours', days: 30 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
const PRESET_DAYS = new Set([1, 7, 30]);
|
||||||
|
|
||||||
|
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange }: PeriodSelectorProps) {
|
||||||
|
const [customOpen, setCustomOpen] = useState(false);
|
||||||
|
const [inputVal, setInputVal] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Ouvre le champ custom avec la valeur courante pré-remplie
|
||||||
|
const openCustom = () => {
|
||||||
|
setInputVal(PRESET_DAYS.has(value) ? '' : String(value));
|
||||||
|
setCustomOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (customOpen) inputRef.current?.focus();
|
||||||
|
}, [customOpen]);
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
const n = parseInt(inputVal, 10);
|
||||||
|
if (n >= 1 && n <= 365) onChange(n);
|
||||||
|
setCustomOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustomActive = !PRESET_DAYS.has(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1">
|
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
|
||||||
{PERIODS.map(({ label, days }) => (
|
{PERIODS.map(({ label, days }) => (
|
||||||
<button
|
<button
|
||||||
key={days}
|
key={days}
|
||||||
onClick={() => onChange(days)}
|
onClick={() => { onChange(days); setCustomOpen(false); }}
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||||
${value === days
|
${value === days && !customOpen
|
||||||
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||||
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
}
|
}
|
||||||
@@ -27,6 +57,73 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
|||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||||
|
|
||||||
|
{/* Bouton Personnaliser + champ inline */}
|
||||||
|
{customOpen ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={inputVal}
|
||||||
|
onChange={(e) => setInputVal(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit();
|
||||||
|
if (e.key === 'Escape') setCustomOpen(false);
|
||||||
|
}}
|
||||||
|
onBlur={commit}
|
||||||
|
placeholder="jours"
|
||||||
|
className="w-16 px-2 py-1 text-sm bg-[#1a1b23] border border-[#d4a843] rounded-md text-[#d4a843] text-center focus:outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<span className="text-[#6b7280] text-xs">j</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={openCustom}
|
||||||
|
className={`
|
||||||
|
px-3 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]'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isCustomActive ? `${value} jours` : 'Personnaliser'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onAnimate}
|
||||||
|
className={`
|
||||||
|
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
|
||||||
|
${animationActive
|
||||||
|
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
|
||||||
|
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
▶ 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-16
@@ -1,11 +1,17 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import type { PeriodStats } from '../services/DataService';
|
import type { PeriodStats } from '../services/DataService';
|
||||||
|
import type { FlowStats } from '../data/arcData';
|
||||||
|
|
||||||
interface StatsPanelProps {
|
interface StatsPanelProps {
|
||||||
stats: PeriodStats | null;
|
stats: PeriodStats | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
periodDays: number;
|
periodDays: number;
|
||||||
source: 'live' | 'mock';
|
source: 'live' | 'mock';
|
||||||
|
currentUD: number;
|
||||||
|
animationLabel?: string;
|
||||||
|
viewMode?: 'heatmap' | 'flow';
|
||||||
|
flowStats?: FlowStats | null;
|
||||||
|
focusCity?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDALS = ['🥇', '🥈', '🥉'];
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
@@ -24,7 +30,35 @@ function StatCard({ label, value, sub, delta }: { label: string; value: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelProps) {
|
function formatDU(g1: number, ud: number): string {
|
||||||
|
const du = g1 / ud;
|
||||||
|
if (du < 10) return `≈ ${du.toFixed(2)} DU`;
|
||||||
|
if (du < 100) return `≈ ${du.toFixed(1)} DU`;
|
||||||
|
return `≈ ${Math.round(du).toLocaleString('fr-FR')} DU`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }: StatsPanelProps) {
|
||||||
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||||
const prevStats = useRef<PeriodStats | null>(null);
|
const prevStats = useRef<PeriodStats | null>(null);
|
||||||
|
|
||||||
@@ -46,9 +80,6 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
|||||||
|
|
||||||
// Mémorise les stats après le rendu
|
// Mémorise les stats après le rendu
|
||||||
if (stats && !loading) prevStats.current = stats;
|
if (stats && !loading) prevStats.current = stats;
|
||||||
const geoPct = stats && stats.transactionCount > 0
|
|
||||||
? Math.round((stats.geoCount / stats.transactionCount) * 100)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
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="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
|
||||||
@@ -58,17 +89,30 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
|||||||
Ğ
|
Ğ
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Period label */}
|
{/* Description */}
|
||||||
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
|
<p className="text-[#6b7280] text-xs leading-relaxed border-t border-[#1e1f2a] pt-3">
|
||||||
Période : <span className="text-[#6b7280]">{periodLabel}</span>
|
Visualisation en temps réel des flux de la monnaie libre <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Period label */}
|
||||||
|
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
|
||||||
|
{animationLabel
|
||||||
|
? <><span className="text-[#d4a843]">▶</span> <span className="text-[#d4a843]">{animationLabel}</span></>
|
||||||
|
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ---- Vue HEATMAP ---- */}
|
||||||
|
{viewMode === 'heatmap' && (
|
||||||
|
<>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[1, 2].map((i) => (
|
{[1, 2].map((i) => (
|
||||||
@@ -80,16 +124,22 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
|||||||
<StatCard
|
<StatCard
|
||||||
label="Volume total"
|
label="Volume total"
|
||||||
value={`${stats.totalVolume.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} Ğ1`}
|
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}
|
delta={prevVolume !== null ? (stats.totalVolume > prevVolume ? 'up' : stats.totalVolume < prevVolume ? 'down' : null) : null}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Transactions"
|
label="Transactions"
|
||||||
value={stats.transactionCount.toLocaleString('fr-FR')}
|
value={stats.transactionCount.toLocaleString('fr-FR')}
|
||||||
sub={`≈ ${(stats.totalVolume / (stats.transactionCount || 1)).toFixed(2)} Ğ1 / tx`}
|
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}
|
delta={prevTxCount !== null ? (stats.transactionCount > prevTxCount ? 'up' : stats.transactionCount < prevTxCount ? 'down' : null) : null}
|
||||||
/>
|
/>
|
||||||
{/* Couverture géo — uniquement en mode live */}
|
{/* Couverture géo — transactionCount inclut le total réel de la frame */}
|
||||||
{source === 'live' && geoPct !== null && (
|
{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="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3">
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
<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-[#4b5563] text-xs uppercase tracking-widest">Géolocalisées</p>
|
||||||
@@ -98,12 +148,13 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
|||||||
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
|
<div className="w-full bg-[#1e1f2a] rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
|
className="bg-[#d4a843] h-1.5 rounded-full transition-all duration-500"
|
||||||
style={{ width: `${geoPct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#4b5563] text-xs mt-1 text-right">{geoPct}% via Cesium+</p>
|
<p className="text-[#4b5563] text-xs mt-1 text-right">{pct}% via Cesium+</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -140,12 +191,108 @@ export function StatsPanel({ stats, loading, periodDays, source }: StatsPanelPro
|
|||||||
))}
|
))}
|
||||||
</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">
|
||||||
|
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
|
||||||
|
{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 */}
|
{/* Footer */}
|
||||||
<div className="mt-auto pt-4 border-t border-[#1e1f2a]">
|
<div className="mt-auto pt-4 border-t border-[#1e1f2a] space-y-1.5">
|
||||||
<p className="text-[#2e2f3a] text-xs text-center">
|
<p className="text-[#2e2f3a] text-xs text-center">
|
||||||
{source === 'live' ? 'Ğ1v2 · Subsquid + Cesium+' : 'Données simulées · mock'}
|
{source === 'live' ? 'Ğ1v2 · Subsquid + Cesium+' : 'Données simulées · mock'}
|
||||||
</p>
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://git.open.us.org/syoul/g1flux"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block text-[#d4a843] hover:text-[#e8c060] text-xs text-center transition-colors"
|
||||||
|
>
|
||||||
|
git.open.us.org/syoul/g1flux
|
||||||
|
</a>
|
||||||
|
<p className="text-[#d4a843] text-xs text-center">
|
||||||
|
Logiciel libre sous licence AGPLv3
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -85,11 +85,15 @@ function generateTransactions(count: number, maxAgeMs: number): Transaction[] {
|
|||||||
return transactions.sort((a, b) => b.timestamp - a.timestamp);
|
return transactions.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POOL_GENERATED_AT = Date.now();
|
||||||
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
|
const TRANSACTION_POOL = generateTransactions(2400, 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
export function getTransactionsForPeriod(periodDays: number): Transaction[] {
|
export function getTransactionsForPeriod(periodDays: number): Transaction[] {
|
||||||
|
const drift = Date.now() - POOL_GENERATED_AT;
|
||||||
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
|
||||||
return TRANSACTION_POOL.filter((tx) => tx.timestamp >= cutoff);
|
return TRANSACTION_POOL
|
||||||
|
.map((tx) => ({ ...tx, timestamp: tx.timestamp + drift }))
|
||||||
|
.filter((tx) => tx.timestamp >= cutoff);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeStats(transactions: Transaction[]) {
|
export function computeStats(transactions: Transaction[]) {
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import type { Transaction } from '../data/mockData';
|
||||||
|
import type { TransactionArc } from '../data/arcData';
|
||||||
|
|
||||||
|
export interface TimeFrame {
|
||||||
|
label: string;
|
||||||
|
from: number; // Unix ms
|
||||||
|
to: number; // Unix ms
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFrames(periodDays: number): TimeFrame[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const start = now - periodDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const fmt = (ms: number, opts: Intl.DateTimeFormatOptions) =>
|
||||||
|
new Date(ms).toLocaleDateString('fr-FR', opts);
|
||||||
|
|
||||||
|
if (periodDays === 1) {
|
||||||
|
return Array.from({ length: 24 }, (_, i) => {
|
||||||
|
const from = start + i * 3_600_000;
|
||||||
|
const to = from + 3_600_000;
|
||||||
|
const h = new Date(from).getHours();
|
||||||
|
return {
|
||||||
|
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} · ${h}h – ${h + 1}h`,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (periodDays === 7) {
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const from = start + i * 86_400_000;
|
||||||
|
const to = from + 86_400_000;
|
||||||
|
return {
|
||||||
|
label: fmt(from, { weekday: 'long', day: 'numeric', month: 'short' }),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30 days → half-week frames (3.5 days ≈ 9–10 frames)
|
||||||
|
const HALF_WEEK = 3.5 * 86_400_000;
|
||||||
|
const frames: TimeFrame[] = [];
|
||||||
|
let cursor = start;
|
||||||
|
while (cursor < now) {
|
||||||
|
const from = cursor;
|
||||||
|
const to = Math.min(cursor + HALF_WEEK, now);
|
||||||
|
frames.push({
|
||||||
|
label: `${fmt(from, { weekday: 'short', day: 'numeric', month: 'short' })} – ${fmt(to - 1, { weekday: 'short', day: 'numeric', month: 'short' })}`,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
cursor = to;
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const [speed, setSpeed] = useState<1 | 2 | 4>(2);
|
||||||
|
|
||||||
|
const frames = useMemo(() => buildFrames(periodDays), [periodDays]);
|
||||||
|
|
||||||
|
// Reset cursor when period or activation changes.
|
||||||
|
// Stop playback only on deactivation — not on activation, so activate() can
|
||||||
|
// start playing immediately without being overridden by this effect.
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentIndex(0);
|
||||||
|
if (!active) setPlaying(false);
|
||||||
|
}, [periodDays, active]);
|
||||||
|
|
||||||
|
// Auto-advance: one step every (2000 / speed) ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || !active) return;
|
||||||
|
const delay = 1500 / speed; // ×1=1500ms, ×2=750ms, ×4=375ms
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setCurrentIndex((i) => {
|
||||||
|
if (i >= frames.length - 1) {
|
||||||
|
setPlaying(false);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return i + 1;
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [playing, active, currentIndex, speed, frames.length]);
|
||||||
|
|
||||||
|
const visibleTransactions = useMemo(() => {
|
||||||
|
if (!active || frames.length === 0) return transactions;
|
||||||
|
const frame = frames[currentIndex];
|
||||||
|
if (!frame) return transactions;
|
||||||
|
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;
|
||||||
|
const frame = frames[currentIndex];
|
||||||
|
if (!frame) return null;
|
||||||
|
return allTimestamps.filter((ts) => ts >= frame.from && ts < frame.to).length;
|
||||||
|
}, [active, allTimestamps, frames, currentIndex]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
activate: () => { setActive(true); setSpeed(1); setPlaying(true); },
|
||||||
|
deactivate: () => { setActive(false); },
|
||||||
|
playing,
|
||||||
|
play: () => setPlaying(true),
|
||||||
|
pause: () => setPlaying(false),
|
||||||
|
currentIndex,
|
||||||
|
seek: (i: number) => { setCurrentIndex(i); setPlaying(false); },
|
||||||
|
speed,
|
||||||
|
setSpeed,
|
||||||
|
frames,
|
||||||
|
currentFrame: frames[currentIndex] ?? null,
|
||||||
|
visibleTransactions,
|
||||||
|
visibleArcs,
|
||||||
|
frameTotalCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
+78
-21
@@ -12,16 +12,30 @@
|
|||||||
* 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 } from './adapters/SubsquidAdapter';
|
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
|
||||||
import { resolveGeoByKeys } from './adapters/CesiumAdapter';
|
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter';
|
||||||
import {
|
import {
|
||||||
getTransactionsForPeriod,
|
getTransactionsForPeriod,
|
||||||
computeStats,
|
computeStats,
|
||||||
type Transaction,
|
type Transaction,
|
||||||
} from '../data/mockData';
|
} from '../data/mockData';
|
||||||
|
import {
|
||||||
|
buildMockArcs,
|
||||||
|
type TransactionArc,
|
||||||
|
} from '../data/arcData';
|
||||||
|
|
||||||
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
|
const USE_LIVE_API = import.meta.env.VITE_USE_LIVE_API === 'true';
|
||||||
|
|
||||||
|
// Cache du DU courant, valide 1 heure (le DU change tous les ~6 mois)
|
||||||
|
let udCache: { value: number; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
async function getCurrentUD(): Promise<number> {
|
||||||
|
if (udCache && Date.now() < udCache.expiresAt) return udCache.value;
|
||||||
|
const value = await fetchCurrentUD();
|
||||||
|
udCache = { value, expiresAt: Date.now() + 60 * 60 * 1000 };
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache de la carte identité SS58→DuniterKey, valide 10 minutes
|
// Cache de la carte identité SS58→DuniterKey, valide 10 minutes
|
||||||
let keyMapCache: { map: Map<string, string>; expiresAt: number } | null = null;
|
let keyMapCache: { map: Map<string, string>; expiresAt: number } | null = null;
|
||||||
|
|
||||||
@@ -34,11 +48,15 @@ async function getIdentityKeyMap(): Promise<Map<string, string>> {
|
|||||||
|
|
||||||
async function fetchLiveTransactions(periodDays: number): Promise<{
|
async function fetchLiveTransactions(periodDays: number): Promise<{
|
||||||
geolocated: Transaction[];
|
geolocated: Transaction[];
|
||||||
|
arcs: TransactionArc[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
totalVolume: number;
|
totalVolume: number;
|
||||||
|
allTimestamps: number[];
|
||||||
}> {
|
}> {
|
||||||
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays);
|
// ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
|
||||||
if (rawTransfers.length === 0) return { geolocated: [], totalCount: 0, totalVolume: 0 };
|
const limit = Math.max(2000, Math.ceil(periodDays * 600));
|
||||||
|
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit);
|
||||||
|
if (rawTransfers.length === 0) return { geolocated: [], arcs: [], totalCount: 0, totalVolume: 0, allTimestamps: [] };
|
||||||
|
|
||||||
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
|
const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
|
||||||
|
|
||||||
@@ -50,15 +68,16 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
|||||||
console.warn('Identity key map indisponible :', err);
|
console.warn('Identity key map indisponible :', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clés Duniter uniques des émetteurs
|
// Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+)
|
||||||
const duniterKeys = [...new Set(
|
const allDuniterKeys = [...new Set([
|
||||||
rawTransfers.map((t) => keyMap.get(t.fromId)).filter(Boolean) as string[]
|
...rawTransfers.map((t) => keyMap.get(t.fromId)),
|
||||||
)];
|
...rawTransfers.map((t) => keyMap.get(t.toId)),
|
||||||
|
].filter(Boolean) as string[])];
|
||||||
|
|
||||||
// Résolution géo par clé Duniter (_id Cesium+)
|
// Résolution géo par clé Duniter (_id Cesium+)
|
||||||
let geoMap = new Map<string, { lat: number; lng: number; city: string; countryCode: string }>();
|
let geoMap = new Map<string, { lat: number; lng: number; city: string; countryCode: string }>();
|
||||||
try {
|
try {
|
||||||
const profiles = await resolveGeoByKeys(duniterKeys);
|
const profiles = await resolveGeoByKeys(allDuniterKeys);
|
||||||
for (const [key, p] of profiles) {
|
for (const [key, p] of profiles) {
|
||||||
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode });
|
geoMap.set(key, { lat: p.lat, lng: p.lng, city: p.city, countryCode: p.countryCode });
|
||||||
}
|
}
|
||||||
@@ -66,28 +85,53 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
|
|||||||
console.warn('Cesium+ indisponible :', err);
|
console.warn('Cesium+ indisponible :', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seules les transactions avec un profil géo entrent dans le heatmap
|
|
||||||
const geolocated: Transaction[] = [];
|
const geolocated: Transaction[] = [];
|
||||||
for (const t of rawTransfers) {
|
const arcs: TransactionArc[] = [];
|
||||||
const duniterKey = keyMap.get(t.fromId);
|
|
||||||
if (!duniterKey) continue;
|
|
||||||
const geo = geoMap.get(duniterKey);
|
|
||||||
if (!geo) continue;
|
|
||||||
|
|
||||||
|
for (const t of rawTransfers) {
|
||||||
|
const fromDuniterKey = keyMap.get(t.fromId);
|
||||||
|
if (!fromDuniterKey) continue;
|
||||||
|
const fromGeo = geoMap.get(fromDuniterKey);
|
||||||
|
if (!fromGeo) continue;
|
||||||
|
|
||||||
|
const fromCity = cleanCityName(fromGeo.city);
|
||||||
|
|
||||||
|
// Heatmap : émetteur géolocalisé
|
||||||
geolocated.push({
|
geolocated.push({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
timestamp: t.timestamp,
|
timestamp: t.timestamp,
|
||||||
lat: geo.lat,
|
lat: fromGeo.lat,
|
||||||
lng: geo.lng,
|
lng: fromGeo.lng,
|
||||||
amount: t.amount,
|
amount: t.amount,
|
||||||
city: geo.city,
|
city: fromCity,
|
||||||
countryCode: geo.countryCode,
|
countryCode: fromGeo.countryCode,
|
||||||
fromKey: t.fromId,
|
fromKey: t.fromId,
|
||||||
toKey: t.toId,
|
toKey: t.toId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Arc : les deux extrémités géolocalisées + villes différentes
|
||||||
|
const toDuniterKey = keyMap.get(t.toId);
|
||||||
|
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 };
|
return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -102,8 +146,11 @@ export interface PeriodStats {
|
|||||||
|
|
||||||
export interface DataResult {
|
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;
|
stats: PeriodStats;
|
||||||
source: 'live' | 'mock';
|
source: 'live' | 'mock';
|
||||||
|
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> {
|
export async function fetchData(periodDays: number): Promise<DataResult> {
|
||||||
@@ -111,18 +158,26 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
|||||||
await new Promise((r) => setTimeout(r, 80));
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
const transactions = getTransactionsForPeriod(periodDays);
|
const transactions = getTransactionsForPeriod(periodDays);
|
||||||
const base = computeStats(transactions);
|
const base = computeStats(transactions);
|
||||||
|
const arcs = buildMockArcs(transactions);
|
||||||
return {
|
return {
|
||||||
transactions,
|
transactions,
|
||||||
|
arcs,
|
||||||
stats: { ...base, geoCount: transactions.length },
|
stats: { ...base, geoCount: transactions.length },
|
||||||
source: 'mock',
|
source: 'mock',
|
||||||
|
currentUD: 11.78,
|
||||||
|
allTimestamps: transactions.map((t) => t.timestamp),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { geolocated, totalCount, totalVolume } = await fetchLiveTransactions(periodDays);
|
const [{ geolocated, arcs, totalCount, totalVolume, allTimestamps }, currentUD] = await Promise.all([
|
||||||
|
fetchLiveTransactions(periodDays),
|
||||||
|
getCurrentUD(),
|
||||||
|
]);
|
||||||
const base = computeStats(geolocated);
|
const base = computeStats(geolocated);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transactions: geolocated,
|
transactions: geolocated,
|
||||||
|
arcs,
|
||||||
stats: {
|
stats: {
|
||||||
totalVolume,
|
totalVolume,
|
||||||
transactionCount: totalCount,
|
transactionCount: totalCount,
|
||||||
@@ -130,5 +185,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
|
|||||||
topCities: base.topCities,
|
topCities: base.topCities,
|
||||||
},
|
},
|
||||||
source: 'live',
|
source: 'live',
|
||||||
|
currentUD,
|
||||||
|
allTimestamps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ function countryCodeFromCity(city: string): string {
|
|||||||
const HitSchema = z.object({
|
const HitSchema = z.object({
|
||||||
_id: z.string(),
|
_id: z.string(),
|
||||||
_source: z.object({
|
_source: z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().nullable().optional(),
|
||||||
city: z.string().optional(),
|
city: z.string().nullable().optional(),
|
||||||
geoPoint: z.unknown().optional(),
|
geoPoint: z.unknown().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const SUBSQUID_ENDPOINT = 'https://squidv2s.syoul.fr/v1/graphql';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const SubsquidTransferNodeSchema = z.object({
|
const SubsquidTransferNodeSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
blockNumber: z.number().int().positive(),
|
blockNumber: z.number().int(), // peut être négatif pour les blocs Ğ1v1 migrés
|
||||||
timestamp: z.string(), // ISO 8601 ex: "2026-03-22T14:53:36+00:00"
|
timestamp: z.string(), // ISO 8601 ex: "2026-03-22T14:53:36+00:00"
|
||||||
amount: z.string(), // BigInt en string, en centimes Ğ1
|
amount: z.string(), // BigInt en string, en centimes Ğ1
|
||||||
fromId: z.string().nullable(),
|
fromId: z.string().nullable(),
|
||||||
@@ -153,6 +153,27 @@ export async function buildIdentityKeyMap(): Promise<Map<string, string>> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retourne la valeur du DU courant en Ğ1 (ex : 11.78). Fallback hardcodé si indisponible. */
|
||||||
|
export async function fetchCurrentUD(): Promise<number> {
|
||||||
|
const UD_FALLBACK = 11.78; // valeur au bloc 225874 — mis à jour si la requête échoue
|
||||||
|
try {
|
||||||
|
const response = await fetch(SUBSQUID_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `{ universalDividends(orderBy: BLOCK_NUMBER_DESC, first: 1) { nodes { amount } } }`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) return UD_FALLBACK;
|
||||||
|
const raw = await response.json();
|
||||||
|
const amountStr: string | undefined = raw?.data?.universalDividends?.nodes?.[0]?.amount;
|
||||||
|
if (!amountStr) return UD_FALLBACK;
|
||||||
|
return parseInt(amountStr, 10) / 100;
|
||||||
|
} catch {
|
||||||
|
return UD_FALLBACK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface FetchTransfersResult {
|
export interface FetchTransfersResult {
|
||||||
transfers: RawTransfer[];
|
transfers: RawTransfer[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare const __APP_VERSION__: string;
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
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({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(version),
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
Reference in New Issue
Block a user