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 { SearchBar } from './components/SearchBar'; import { fetchData, fetchMemberCities } from './services/DataService'; import type { PeriodStats, MemberCity } from './services/DataService'; import type { Transaction } from './data/mockData'; import type { TransactionArc } from './data/arcData'; import { computeStats } from './data/mockData'; import { computeFlowStats } from './data/arcData'; import { useAnimation } from './hooks/useAnimation'; import { useMediaQuery } from './hooks/useMediaQuery'; import { InfoPanel } from './components/InfoPanel'; import { initialUrlState, useUrlSync } from './hooks/useUrlState'; export default function App() { const [periodDays, setPeriodDays] = useState(initialUrlState.period); const [transactions, setTransactions] = useState([]); const [arcs, setArcs] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [lastUpdate, setLastUpdate] = useState(null); const [source, setSource] = useState<'live' | 'mock'>('mock'); const [currentUD, setCurrentUD] = useState(11.78); const [allTimestamps, setAllTimestamps] = useState([]); const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>(initialUrlState.view); const [focusCity, setFocusCity] = useState(initialUrlState.city); const [panelOpen, setPanelOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false); const [showMembers, setShowMembers] = useState(false); const [memberCities, setMemberCities] = useState([]); const [membersLoading, setMembersLoading] = useState(false); const [endpointVersion, setEndpointVersion] = useState(0); const isMobile = useMediaQuery('(max-width: 639px)'); const toggleMembers = async () => { if (showMembers) { setShowMembers(false); return; } if (memberCities.length > 0) { setShowMembers(true); return; } setMembersLoading(true); try { const cities = await fetchMemberCities(); setMemberCities(cities); setShowMembers(true); } catch (err) { console.warn('fetchMemberCities error:', err); } finally { setMembersLoading(false); } }; const animation = useAnimation(transactions, arcs, periodDays, allTimestamps); // Synchronise l'état dans l'URL (deep link / partage) useUrlSync(periodDays, viewMode, focusCity); const handlePeriodChange = (days: number) => { animation.deactivate(); setPeriodDays(days); }; const handleViewModeChange = (mode: 'heatmap' | 'flow') => { setViewMode(mode); setFocusCity(null); }; // Raccourcis clavier useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.key === 'ArrowLeft' && animation.active) { animation.seek(Math.max(0, animation.currentIndex - 1)); e.preventDefault(); } else if (e.key === 'ArrowRight' && animation.active) { animation.seek(Math.min(animation.frames.length - 1, animation.currentIndex + 1)); e.preventDefault(); } else if (e.key === ' ' && animation.active) { animation.playing ? animation.pause() : animation.play(); e.preventDefault(); } else if (e.key === 'Escape') { if (infoOpen) { setInfoOpen(false); e.preventDefault(); } else if (animation.active) { animation.deactivate(); e.preventDefault(); } } else if (e.key === 'h' || e.key === 'H') { handleViewModeChange(viewMode === 'heatmap' ? 'flow' : 'heatmap'); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [animation.active, animation.playing, animation.currentIndex, animation.frames.length, infoOpen, viewMode]); 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), 120_000); return () => { cancelled = true; clearInterval(interval); }; }, [periodDays, endpointVersion]); // 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, allTimestamps, onEndpointChange: () => setEndpointVersion((v) => v + 1), transactions, }; return (
{/* Side panel — desktop uniquement */} {!isMobile && } {/* Map area */}
{viewMode === 'heatmap' ? ( ) : ( )} {/* Bouton menu — mobile uniquement */} {isMobile && ( )} {/* Bouton info — sous ☰ sur mobile, top-left sur desktop */} {/* Barre de recherche identité */}
{ setViewMode('flow'); setFocusCity(city); }} />
{/* Toggle overlay membres DU */} {/* Period selector — floating over map */}
animation.active ? animation.deactivate() : animation.activate()} viewMode={viewMode} onViewModeChange={handleViewModeChange} geoPercent={visibleStats && visibleStats.transactionCount > 0 ? Math.round((visibleStats.geoCount / visibleStats.transactionCount) * 100) : null} />
{/* Badge ville focus — mobile uniquement */} {isMobile && focusCity && (
{focusCity}
)} {/* Transaction count + source badge (masqués sur mobile et en mode animation) */} {!loading && !animation.active && !isMobile && (
{transactions.length} transactions affichées
{source === 'live' ? <>{refreshing ? : '●'} live Ğ1v2{lastUpdate && {lastUpdate.toLocaleTimeString('fr-FR')}} : '○ mock'}
)} {/* Animation player */} {animation.active && ( )} {/* Loading overlay */} {loading && (

Chargement des flux…

)}
{/* Info panel */} {infoOpen && setInfoOpen(false)} />} {/* Bottom drawer — mobile uniquement */} {isMobile && ( <> {/* Overlay */} {panelOpen && (
setPanelOpen(false)} /> )} {/* Drawer */}
setPanelOpen(false)} className="w-full flex-1 min-h-0" />
)}
); }