ffe09ea44a
ci/woodpecker/push/woodpecker Pipeline was successful
Ajoute un badge "XX% géo" à droite du bouton Flux/Heatmap dans PeriodSelector, mis à jour à chaque frame d'animation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
223 lines
8.8 KiB
TypeScript
223 lines
8.8 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { StatsPanel } from './components/StatsPanel';
|
|
import { PeriodSelector } from './components/PeriodSelector';
|
|
import { HeatMap } from './components/HeatMap';
|
|
import { FlowMap } from './components/FlowMap';
|
|
import { AnimationPlayer } from './components/AnimationPlayer';
|
|
import { fetchData } from './services/DataService';
|
|
import type { PeriodStats } from './services/DataService';
|
|
import type { Transaction } from './data/mockData';
|
|
import type { TransactionArc } from './data/arcData';
|
|
import { computeStats } from './data/mockData';
|
|
import { computeFlowStats } from './data/arcData';
|
|
import { useAnimation } from './hooks/useAnimation';
|
|
import { useMediaQuery } from './hooks/useMediaQuery';
|
|
|
|
export default function App() {
|
|
const [periodDays, setPeriodDays] = useState(7);
|
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
const [arcs, setArcs] = useState<TransactionArc[]>([]);
|
|
const [stats, setStats] = useState<PeriodStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
|
const [source, setSource] = useState<'live' | 'mock'>('mock');
|
|
const [currentUD, setCurrentUD] = useState<number>(11.78);
|
|
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
|
|
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
|
const [focusCity, setFocusCity] = useState<string | null>(null);
|
|
const [panelOpen, setPanelOpen] = useState(false);
|
|
const isMobile = useMediaQuery('(max-width: 639px)');
|
|
|
|
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
|
|
|
const handlePeriodChange = (days: number) => {
|
|
animation.deactivate();
|
|
setPeriodDays(days);
|
|
};
|
|
|
|
const handleViewModeChange = (mode: 'heatmap' | 'flow') => {
|
|
setViewMode(mode);
|
|
setFocusCity(null);
|
|
};
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const load = (showLoading: boolean) => {
|
|
if (showLoading) setLoading(true);
|
|
else setRefreshing(true);
|
|
fetchData(periodDays)
|
|
.then(({ transactions, arcs, stats, source, currentUD, allTimestamps }) => {
|
|
if (!cancelled) {
|
|
setTransactions(transactions);
|
|
setArcs(arcs);
|
|
setStats(stats);
|
|
setSource(source);
|
|
setCurrentUD(currentUD);
|
|
setAllTimestamps(allTimestamps);
|
|
setLastUpdate(new Date());
|
|
}
|
|
})
|
|
.catch((err) => console.warn('Ğ1Flux refresh error:', err))
|
|
.finally(() => {
|
|
if (!cancelled) { setLoading(false); setRefreshing(false); }
|
|
});
|
|
};
|
|
|
|
load(true);
|
|
const interval = setInterval(() => load(false), 30_000);
|
|
|
|
return () => { cancelled = true; clearInterval(interval); };
|
|
}, [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],
|
|
);
|
|
|
|
const statsPanelProps = {
|
|
stats: visibleStats,
|
|
loading,
|
|
periodDays,
|
|
source,
|
|
currentUD,
|
|
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
|
|
viewMode,
|
|
flowStats,
|
|
focusCity,
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
|
|
{/* Side panel — desktop uniquement */}
|
|
{!isMobile && <StatsPanel {...statsPanelProps} />}
|
|
|
|
{/* Map area */}
|
|
<div className="relative flex-1 min-w-0">
|
|
{viewMode === 'heatmap' ? (
|
|
<HeatMap transactions={animation.visibleTransactions} />
|
|
) : (
|
|
<FlowMap
|
|
arcs={animation.active ? animation.visibleArcs : arcs}
|
|
focusCity={focusCity}
|
|
onCityClick={setFocusCity}
|
|
/>
|
|
)}
|
|
|
|
{/* Bouton menu — mobile uniquement */}
|
|
{isMobile && (
|
|
<button
|
|
onClick={() => setPanelOpen(true)}
|
|
className="absolute top-4 left-4 z-[1001] w-10 h-10 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-xl flex items-center justify-center text-[#d4a843] text-lg"
|
|
aria-label="Ouvrir le panneau"
|
|
>
|
|
☰
|
|
</button>
|
|
)}
|
|
|
|
{/* Period selector — floating over map */}
|
|
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
|
|
<PeriodSelector
|
|
value={periodDays}
|
|
onChange={handlePeriodChange}
|
|
animationActive={animation.active}
|
|
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
|
viewMode={viewMode}
|
|
onViewModeChange={handleViewModeChange}
|
|
geoPercent={visibleStats && visibleStats.transactionCount > 0
|
|
? Math.round((visibleStats.geoCount / visibleStats.transactionCount) * 100)
|
|
: null}
|
|
/>
|
|
</div>
|
|
|
|
{/* Badge ville focus — mobile uniquement */}
|
|
{isMobile && focusCity && (
|
|
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-[1001] flex items-center gap-2 bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#d4a843]/40 rounded-full px-3 py-1.5">
|
|
<span className="text-[#d4a843] text-xs font-medium">{focusCity}</span>
|
|
<button onClick={() => setFocusCity(null)} className="text-[#4b5563] hover:text-white text-xs leading-none">✕</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Transaction count + source badge (masqués sur mobile et en mode animation) */}
|
|
{!loading && !animation.active && !isMobile && (
|
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-[1000] flex items-center gap-2">
|
|
<div className="bg-[#0a0b0f]/80 backdrop-blur-sm border border-[#2e2f3a] rounded-full px-4 py-1.5 text-xs text-[#6b7280]">
|
|
<span className="text-[#d4a843] font-medium">{transactions.length}</span> transactions affichées
|
|
</div>
|
|
<div className={`backdrop-blur-sm border rounded-full px-3 py-1.5 text-xs font-medium ${
|
|
source === 'live'
|
|
? 'bg-emerald-950/80 border-emerald-700 text-emerald-400'
|
|
: 'bg-[#0a0b0f]/80 border-[#2e2f3a] text-[#4b5563]'
|
|
}`}>
|
|
{source === 'live'
|
|
? <>{refreshing ? <span className="animate-spin inline-block">↻</span> : '●'} live Ğ1v2{lastUpdate && <span className="ml-1 opacity-60">{lastUpdate.toLocaleTimeString('fr-FR')}</span>}</>
|
|
: '○ mock'}
|
|
</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 && (
|
|
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-[#0a0b0f]/60 backdrop-blur-sm">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full border-2 border-[#d4a843] border-t-transparent animate-spin" />
|
|
<p className="text-[#d4a843] text-sm">Chargement des flux…</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom drawer — mobile uniquement */}
|
|
{isMobile && (
|
|
<>
|
|
{/* Overlay */}
|
|
{panelOpen && (
|
|
<div
|
|
className="fixed inset-0 z-[1009] bg-black/50"
|
|
onClick={() => setPanelOpen(false)}
|
|
/>
|
|
)}
|
|
{/* Drawer */}
|
|
<div
|
|
className={`fixed bottom-0 left-0 right-0 z-[1010] h-[85vh] flex flex-col transition-transform duration-300 ${panelOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
|
>
|
|
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
|
|
<div className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
|
|
</div>
|
|
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|