6b42a75140
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
316 lines
13 KiB
TypeScript
316 lines
13 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 { 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<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'>(initialUrlState.view);
|
||
const [focusCity, setFocusCity] = useState<string | null>(initialUrlState.city);
|
||
const [panelOpen, setPanelOpen] = useState(false);
|
||
const [infoOpen, setInfoOpen] = useState(false);
|
||
const [showMembers, setShowMembers] = useState(false);
|
||
const [memberCities, setMemberCities] = useState<MemberCity[]>([]);
|
||
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 (
|
||
<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}
|
||
memberCities={showMembers ? memberCities : []}
|
||
/>
|
||
) : (
|
||
<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>
|
||
)}
|
||
|
||
{/* Bouton info — sous ☰ sur mobile, top-left sur desktop */}
|
||
<button
|
||
onClick={() => setInfoOpen(true)}
|
||
className={`absolute ${isMobile ? 'top-16' : '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-[#6b7280] hover:text-[#d4a843] transition-colors text-base`}
|
||
aria-label="Aide"
|
||
>
|
||
ℹ
|
||
</button>
|
||
|
||
{/* Barre de recherche identité */}
|
||
<div className={`absolute ${isMobile ? 'top-28' : 'top-16'} left-4 z-[1001]`}>
|
||
<SearchBar
|
||
onResult={(city) => {
|
||
setViewMode('flow');
|
||
setFocusCity(city);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Toggle overlay membres DU */}
|
||
<button
|
||
onClick={toggleMembers}
|
||
disabled={membersLoading}
|
||
title={showMembers ? 'Masquer les membres' : 'Afficher les membres Ğ1 actifs géolocalisés'}
|
||
className={`absolute ${isMobile ? 'top-40' : 'top-28'} left-4 z-[1001] w-10 h-10 backdrop-blur-sm border rounded-xl flex items-center justify-center text-sm transition-colors
|
||
${showMembers
|
||
? 'bg-[#00c853]/20 border-[#00c853]/60 text-[#00c853]'
|
||
: 'bg-[#0a0b0f]/90 border-[#2e2f3a] text-[#6b7280] hover:text-[#00c853]'
|
||
}`}
|
||
aria-label="Membres DU"
|
||
>
|
||
{membersLoading ? <span className="animate-spin inline-block text-xs">↻</span> : 'DU'}
|
||
</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}
|
||
/>
|
||
)}
|
||
|
||
{/* 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>
|
||
|
||
{/* Info panel */}
|
||
{infoOpen && <InfoPanel onClose={() => setInfoOpen(false)} />}
|
||
|
||
{/* 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)} className="w-full flex-1 min-h-0" />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|