Files
g1flux/src/App.tsx
T
syoul 0d9415ae6a
ci/woodpecker/push/woodpecker Pipeline was successful
feat: indicateurs de statut et configuration des endpoints SubSquid/Cesium+
- Dots de statut en temps réel dans le StatsPanel (ok/slow/error + latence)
- Bannière d'alerte si un service est inaccessible
- EndpointPopover : sélection parmi nœuds connus, test de latence live, URL custom
- Rechargement automatique des données après changement d'endpoint
- SubsquidAdapter et CesiumAdapter lisent l'URL active depuis EndpointConfig
- InfoPanel mis à jour (overlay DU + statut des services)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:43:33 +02:00

315 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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), 30_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),
};
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>
);
}