feat: vue flux — arcs dirigés entre villes géolocalisées
ci/woodpecker/push/woodpecker Pipeline was successful

- Nouveau type TransactionArc + buildCorridors + computeFlowStats
- FlowMap : SVG overlay Leaflet, arcs bezier, flèches de direction, nœuds de villes cliquables
- Clic sur une ville : arcs sortants orange, entrants teal, reste grisé
- DataService : résolution géo des destinataires (toId) dans le même appel Cesium+
- useAnimation : expose visibleArcs filtré par frame
- PeriodSelector : bouton toggle Heatmap / Flux
- StatsPanel : stats flux (volume, top émetteurs, top récepteurs, balance nette)
- App : state viewMode + focusCity, FlowMap conditionnel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-24 00:21:03 +01:00
parent ab72d8218b
commit 97ff22027c
7 changed files with 647 additions and 112 deletions
+39 -6
View File
@@ -1,17 +1,21 @@
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 { 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 { computeStats } from './data/mockData';
import { computeFlowStats } from './data/arcData';
import { useAnimation } from './hooks/useAnimation'; 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);
@@ -19,14 +23,21 @@ export default function App() {
const [source, setSource] = useState<'live' | 'mock'>('mock'); const [source, setSource] = useState<'live' | 'mock'>('mock');
const [currentUD, setCurrentUD] = useState<number>(11.78); const [currentUD, setCurrentUD] = useState<number>(11.78);
const [allTimestamps, setAllTimestamps] = useState<number[]>([]); const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
const [focusCity, setFocusCity] = useState<string | null>(null);
const animation = useAnimation(transactions, periodDays, allTimestamps); const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
const handlePeriodChange = (days: number) => { const handlePeriodChange = (days: number) => {
animation.deactivate(); animation.deactivate();
setPeriodDays(days); setPeriodDays(days);
}; };
const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
setViewMode(mode);
setFocusCity(null);
};
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -34,9 +45,10 @@ 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, currentUD, allTimestamps }) => { .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); setCurrentUD(currentUD);
@@ -56,16 +68,24 @@ export default function App() {
return () => { cancelled = true; clearInterval(interval); }; return () => { cancelled = true; clearInterval(interval); };
}, [periodDays]); }, [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 const visibleStats: PeriodStats | null = animation.active
? { ? {
...computeStats(animation.visibleTransactions), ...computeStats(animation.visibleTransactions),
geoCount: animation.visibleTransactions.length, geoCount: animation.visibleTransactions.length,
// frameTotalCount = total réel (géo + non-géo) dans cette frame
transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length, transactionCount: animation.frameTotalCount ?? animation.visibleTransactions.length,
} }
: stats; : 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 */}
@@ -76,11 +96,22 @@ export default function App() {
source={source} source={source}
currentUD={currentUD} currentUD={currentUD}
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined} 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={animation.visibleTransactions} /> {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]">
@@ -89,6 +120,8 @@ export default function App() {
onChange={handlePeriodChange} onChange={handlePeriodChange}
animationActive={animation.active} animationActive={animation.active}
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()} onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
/> />
</div> </div>
+209
View File
@@ -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>
);
}
+18 -1
View File
@@ -5,6 +5,8 @@ interface PeriodSelectorProps {
onChange: (days: number) => void; onChange: (days: number) => void;
animationActive: boolean; animationActive: boolean;
onAnimate: () => void; onAnimate: () => void;
viewMode: 'heatmap' | 'flow';
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
} }
const PERIODS = [ const PERIODS = [
@@ -15,7 +17,7 @@ const PERIODS = [
const PRESET_DAYS = new Set([1, 7, 30]); 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 }: PeriodSelectorProps) {
const [customOpen, setCustomOpen] = useState(false); const [customOpen, setCustomOpen] = useState(false);
const [inputVal, setInputVal] = useState(''); const [inputVal, setInputVal] = useState('');
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -107,6 +109,21 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate }:
> >
Animer Animer
</button> </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>
); );
} }
+190 -78
View File
@@ -1,5 +1,6 @@
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;
@@ -8,6 +9,9 @@ interface StatsPanelProps {
source: 'live' | 'mock'; source: 'live' | 'mock';
currentUD: number; currentUD: number;
animationLabel?: string; animationLabel?: string;
viewMode?: 'heatmap' | 'flow';
flowStats?: FlowStats | null;
focusCity?: string | null;
} }
const MEDALS = ['🥇', '🥈', '🥉']; const MEDALS = ['🥇', '🥈', '🥉'];
@@ -33,7 +37,28 @@ function formatDU(g1: number, ud: number): string {
return `${Math.round(du).toLocaleString('fr-FR')} DU`; 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 }: 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);
@@ -85,84 +110,171 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
} }
</p> </p>
{/* Stats */} {/* ---- Vue HEATMAP ---- */}
{loading ? ( {viewMode === 'heatmap' && (
<div className="space-y-3"> <>
{[1, 2].map((i) => ( {loading ? (
<div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" /> <div className="space-y-3">
))} {[1, 2].map((i) => (
</div> <div key={i} className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-4 h-20 animate-pulse" />
) : 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>
))} ) : stats ? (
</div> <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">
<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 &nbsp;
<span className="text-[#00acc1]"></span> entrants
</p>
</div>
)}
</div>
) : (
<p className="text-[#4b5563] text-xs">Aucun arc à afficher.</p>
)}
</>
)} )}
{/* Footer */} {/* Footer */}
+120
View File
@@ -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;
}
+10 -1
View File
@@ -1,5 +1,6 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import type { Transaction } from '../data/mockData'; import type { Transaction } from '../data/mockData';
import type { TransactionArc } from '../data/arcData';
export interface TimeFrame { export interface TimeFrame {
label: string; label: string;
@@ -56,7 +57,7 @@ function buildFrames(periodDays: number): TimeFrame[] {
return frames; 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 [active, setActive] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [playing, setPlaying] = useState(false); 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); return transactions.filter((t) => t.timestamp >= frame.from && t.timestamp < frame.to);
}, [active, transactions, frames, currentIndex]); }, [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 // Nombre total de transfers (géo + non-géo) dans la frame courante
const frameTotalCount = useMemo(() => { const frameTotalCount = useMemo(() => {
if (!active || frames.length === 0 || allTimestamps.length === 0) return null; if (!active || frames.length === 0 || allTimestamps.length === 0) return null;
@@ -117,6 +125,7 @@ export function useAnimation(transactions: Transaction[], periodDays: number, al
frames, frames,
currentFrame: frames[currentIndex] ?? null, currentFrame: frames[currentIndex] ?? null,
visibleTransactions, visibleTransactions,
visibleArcs,
frameTotalCount, frameTotalCount,
}; };
} }
+61 -26
View File
@@ -13,12 +13,16 @@
*/ */
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } 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';
@@ -43,15 +47,16 @@ async function getIdentityKeyMap(): Promise<Map<string, string>> {
} }
async function fetchLiveTransactions(periodDays: number): Promise<{ async function fetchLiveTransactions(periodDays: number): Promise<{
geolocated: Transaction[]; geolocated: Transaction[];
totalCount: number; arcs: TransactionArc[];
totalVolume: number; totalCount: number;
totalVolume: number;
allTimestamps: number[]; allTimestamps: number[];
}> { }> {
// ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000 // ~400 tx/jour sur le réseau Ğ1v2 → marge ×1.5 arrondie, minimum 2000
const limit = Math.max(2000, Math.ceil(periodDays * 600)); const limit = Math.max(2000, Math.ceil(periodDays * 600));
const { transfers: rawTransfers, totalCount } = await fetchTransfers(periodDays, limit); 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); const totalVolume = rawTransfers.reduce((s, t) => s + t.amount, 0);
@@ -63,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 });
} }
@@ -79,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[] = []; const arcs: TransactionArc[] = [];
for (const t of rawTransfers) {
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, allTimestamps: rawTransfers.map((t) => t.timestamp) }; return { geolocated, arcs, totalCount, totalVolume, allTimestamps: rawTransfers.map((t) => t.timestamp) };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -114,11 +145,12 @@ 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 currentUD: number; // valeur du DU courant en Ğ1
allTimestamps: number[]; // timestamps de TOUS les transfers (géo + non-géo) 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> {
@@ -126,8 +158,10 @@ 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, currentUD: 11.78,
@@ -135,7 +169,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), fetchLiveTransactions(periodDays),
getCurrentUD(), getCurrentUD(),
]); ]);
@@ -143,6 +177,7 @@ export async function fetchData(periodDays: number): Promise<DataResult> {
return { return {
transactions: geolocated, transactions: geolocated,
arcs,
stats: { stats: {
totalVolume, totalVolume,
transactionCount: totalCount, transactionCount: totalCount,