Files
g1flux/src/App.tsx
T
syoul 16cebb6ec9
ci/woodpecker/push/woodpecker Pipeline was successful
feat: adaptation mobile — drawer bottom + layout responsive
Sur smartphone (< 640px) : panneau stats masqué par défaut, accessible
via un bottom drawer animé (bouton ☰). PeriodSelector passe en flex-wrap
avec padding tactile 44px. AnimationPlayer s'adapte à la largeur écran.
Badge ville focus affiché directement sur la carte en mode mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:07:48 +01:00

220 lines
8.6 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}
/>
</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>
);
}