feat: raccourcis clavier, URL partageable, sparkline, recherche identité
ci/woodpecker/push/woodpecker Pipeline was successful

- Raccourcis clavier : ←/→ (frames), Espace (play/pause), Échap
  (quitter animation/fermer info), H (basculer heatmap↔flux)
- URL partageable : ?period=7&view=flow&city=Paris — état restauré
  au chargement et mis à jour sans rechargement (history.replaceState)
- Sparkline : mini bar-chart SVG dans le StatsPanel montrant l'activité
  sur la période (données déjà en mémoire, aucune requête)
- Recherche identité : champ flottant (⌕) acceptant un nom Ğ1 ou une
  clé g1…, résout via Subsquid + Cesium+, bascule en vue flux et
  met la ville en focus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
syoul
2026-03-28 12:28:58 +01:00
parent 8f9a11c4e8
commit 575ca7a1fc
5 changed files with 315 additions and 11 deletions
+44 -3
View File
@@ -4,6 +4,7 @@ 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 } from './services/DataService';
import type { PeriodStats } from './services/DataService';
import type { Transaction } from './data/mockData';
@@ -13,9 +14,10 @@ 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(7);
const [periodDays, setPeriodDays] = useState(initialUrlState.period);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [arcs, setArcs] = useState<TransactionArc[]>([]);
const [stats, setStats] = useState<PeriodStats | null>(null);
@@ -25,14 +27,17 @@ export default function App() {
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 [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 isMobile = useMediaQuery('(max-width: 639px)');
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);
@@ -43,6 +48,31 @@ export default function App() {
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;
@@ -101,6 +131,7 @@ export default function App() {
viewMode,
flowStats,
focusCity,
allTimestamps,
};
return (
@@ -140,6 +171,16 @@ export default function App() {
</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>
{/* 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