From 575ca7a1fc5b31198ccce131984de398f341f98d Mon Sep 17 00:00:00 2001 From: syoul Date: Sat, 28 Mar 2026 12:28:58 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20raccourcis=20clavier,=20URL=20partageab?= =?UTF-8?q?le,=20sparkline,=20recherche=20identit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 47 ++++++++++- src/components/SearchBar.tsx | 151 ++++++++++++++++++++++++++++++++++ src/components/Sparkline.tsx | 66 +++++++++++++++ src/components/StatsPanel.tsx | 23 ++++-- src/hooks/useUrlState.ts | 39 +++++++++ 5 files changed, 315 insertions(+), 11 deletions(-) create mode 100644 src/components/SearchBar.tsx create mode 100644 src/components/Sparkline.tsx create mode 100644 src/hooks/useUrlState.ts diff --git a/src/App.tsx b/src/App.tsx index b92e8a2..89c6776 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]); const [arcs, setArcs] = useState([]); const [stats, setStats] = useState(null); @@ -25,14 +27,17 @@ export default function App() { const [source, setSource] = useState<'live' | 'mock'>('mock'); const [currentUD, setCurrentUD] = useState(11.78); const [allTimestamps, setAllTimestamps] = useState([]); - const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap'); - const [focusCity, setFocusCity] = useState(null); + 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 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() { ℹ + {/* Barre de recherche identité */} +
+ { + setViewMode('flow'); + setFocusCity(city); + }} + /> +
+ {/* Period selector — floating over map */}
void; +} + +async function resolveQuery(query: string): Promise<{ name: string; city: string } | null> { + const q = query.trim(); + if (!q) return null; + + // Clé SS58 Ğ1v2 : commence par "g1" et fait ~50 caractères + const isKey = /^g1[1-9A-HJ-NP-Za-km-z]{40,}$/.test(q); + + let duniterKey: string; + let identityName: string; + + if (isKey) { + duniterKey = ss58ToDuniterKey(q); + identityName = q.slice(0, 10) + '…'; + } else { + const res = await fetch(SUBSQUID_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query($q: String!) { + identities(filter: { name: { includesInsensitive: $q } }, first: 1) { + nodes { + accountId + name + ownerKeyChange(orderBy: BLOCK_NUMBER_ASC, first: 1) { + nodes { previousId } + } + } + } + } + `, + variables: { q }, + }), + }); + if (!res.ok) throw new Error(`Subsquid HTTP ${res.status}`); + const data = await res.json(); + const node = data?.data?.identities?.nodes?.[0]; + if (!node) return null; + + const genesisKey: string = node.ownerKeyChange.nodes[0]?.previousId ?? node.accountId; + duniterKey = ss58ToDuniterKey(genesisKey); + identityName = node.name as string; + } + + const geoMap = await resolveGeoByKeys([duniterKey]); + const geo = geoMap.get(duniterKey); + if (!geo) return null; + + return { name: identityName, city: geo.city.split(',')[0].trim() }; +} + +export function SearchBar({ onResult }: SearchBarProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [found, setFound] = useState<{ name: string; city: string } | null>(null); + + const close = () => { setOpen(false); setQuery(''); setError(null); setFound(null); }; + + const handleSubmit = async () => { + if (!query.trim()) return; + setLoading(true); + setError(null); + setFound(null); + try { + const result = await resolveQuery(query); + if (result) setFound(result); + else setError('Introuvable dans Cesium+'); + } catch { + setError('Erreur de connexion'); + } finally { + setLoading(false); + } + }; + + const handleSelect = () => { + if (!found) return; + onResult(found.city); + close(); + }; + + if (!open) { + return ( + + ); + } + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSubmit(); + if (e.key === 'Escape') close(); + }} + placeholder="Nom ou clé g1…" + className="flex-1 min-w-0 bg-[#0f1016] border border-[#2e2f3a] rounded-lg px-2 py-1.5 text-xs text-white placeholder-[#4b5563] focus:outline-none focus:border-[#d4a843] transition-colors" + /> + + +
+ + {error && ( +

{error}

+ )} + + {found && ( + + )} +
+ ); +} diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx new file mode 100644 index 0000000..fbfd2f0 --- /dev/null +++ b/src/components/Sparkline.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; + +interface SparklineProps { + timestamps: number[]; + periodDays: number; +} + +/** + * Mini bar-chart SVG affichant l'activité journalière sur la période. + * Utilise les timestamps déjà en mémoire — aucune requête supplémentaire. + */ +export function Sparkline({ timestamps, periodDays }: SparklineProps) { + const buckets = useMemo(() => { + if (timestamps.length === 0) return []; + const n = periodDays === 1 ? 24 : Math.min(periodDays, 30); + const now = Date.now(); + const start = now - periodDays * 864e5; + const step = (periodDays * 864e5) / n; + const counts = new Array(n).fill(0); + for (const ts of timestamps) { + const i = Math.floor((ts - start) / step); + if (i >= 0 && i < n) counts[i]++; + } + return counts; + }, [timestamps, periodDays]); + + if (buckets.length === 0) return null; + + const n = buckets.length; + const max = Math.max(...buckets, 1); + const W = 100; + const H = 32; + const barW = W / n; + const gap = barW * 0.18; + + return ( +
+ +
+ {periodDays === 1 ? '0h' : 'J-' + periodDays} + {periodDays === 1 ? 'maintenant' : 'aujourd\'hui'} +
+
+ ); +} diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index 37d50fc..53bd3ad 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -1,6 +1,7 @@ import { useRef } from 'react'; import type { PeriodStats } from '../services/DataService'; import type { FlowStats } from '../data/arcData'; +import { Sparkline } from './Sparkline'; interface StatsPanelProps { stats: PeriodStats | null; @@ -14,6 +15,7 @@ interface StatsPanelProps { flowStats?: FlowStats | null; focusCity?: string | null; onClose?: () => void; + allTimestamps?: number[]; } const MEDALS = ['🥇', '🥈', '🥉']; @@ -60,7 +62,7 @@ function CityRow({ city, volume, count, countryCode, accent }: { ); } -export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className }: StatsPanelProps) { +export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className, allTimestamps = [] }: StatsPanelProps) { const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const prevStats = useRef(null); @@ -113,13 +115,18 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim Visualisation en temps réel des flux de la monnaie libre Ğ1 sur une carte mondiale.

- {/* Period label */} -

- {animationLabel - ? <> {animationLabel} - : <>Période : {periodLabel} - } -

+ {/* Period label + sparkline */} +
+

+ {animationLabel + ? <> {animationLabel} + : <>Période : {periodLabel} + } +

+ {!animationLabel && allTimestamps.length > 0 && ( + + )} +
{/* ---- Vue HEATMAP ---- */} {viewMode === 'heatmap' && ( diff --git a/src/hooks/useUrlState.ts b/src/hooks/useUrlState.ts new file mode 100644 index 0000000..061dd92 --- /dev/null +++ b/src/hooks/useUrlState.ts @@ -0,0 +1,39 @@ +/** + * useUrlState — synchronisation bidirectionnelle de l'état App ↔ URL. + * + * Lecture initiale : appelée une fois au démarrage (module-level). + * Écriture : useUrlSync() à appeler dans App pour maintenir l'URL à jour. + * + * Paramètres supportés : + * ?period=7&view=flow&city=Paris + */ +import { useEffect } from 'react'; + +function parseInitialState(): { period: number; view: 'heatmap' | 'flow'; city: string | null } { + const p = new URLSearchParams(window.location.search); + const period = parseInt(p.get('period') ?? '', 10); + return { + period: Number.isFinite(period) && period >= 1 && period <= 365 ? period : 7, + view: p.get('view') === 'flow' ? 'flow' : 'heatmap', + city: p.get('city') ?? null, + }; +} + +/** Valeurs lues depuis l'URL au chargement de la page. */ +export const initialUrlState = parseInitialState(); + +/** Écrit l'état courant dans l'URL (history.replaceState, sans recharger). */ +export function useUrlSync( + periodDays: number, + viewMode: 'heatmap' | 'flow', + focusCity: string | null, +) { + useEffect(() => { + const p = new URLSearchParams(); + if (periodDays !== 7) p.set('period', String(periodDays)); + if (viewMode !== 'heatmap') p.set('view', viewMode); + if (focusCity) p.set('city', focusCity); + const qs = p.toString(); + history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); + }, [periodDays, viewMode, focusCity]); +}