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
+151
View File
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { ss58ToDuniterKey, SUBSQUID_ENDPOINT } from '../services/adapters/SubsquidAdapter';
import { resolveGeoByKeys } from '../services/adapters/CesiumAdapter';
interface SearchBarProps {
/** Appelé quand une ville est trouvée — App bascule en vue flux et met la ville en focus. */
onResult: (city: string) => 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<string | null>(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 (
<button
onClick={() => setOpen(true)}
className="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-sm"
aria-label="Rechercher une identité Ğ1"
title="Rechercher une identité (nom ou clé g1…)"
>
</button>
);
}
return (
<div className="flex flex-col gap-1.5 bg-[#0a0b0f]/95 backdrop-blur-sm border border-[#2e2f3a] rounded-xl p-2 w-60 shadow-xl">
<div className="flex gap-1 items-center">
<input
autoFocus
value={query}
onChange={(e) => 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"
/>
<button
onClick={handleSubmit}
disabled={loading || !query.trim()}
className="text-[#d4a843] disabled:text-[#4b5563] text-sm px-1.5 hover:text-white transition-colors shrink-0"
aria-label="Rechercher"
>
{loading ? <span className="animate-spin inline-block"></span> : '↵'}
</button>
<button
onClick={close}
className="text-[#4b5563] hover:text-white transition-colors shrink-0 text-xs"
aria-label="Fermer"
>
</button>
</div>
{error && (
<p className="text-red-400 text-xs px-1">{error}</p>
)}
{found && (
<button
onClick={handleSelect}
className="text-left px-2 py-2 rounded-lg bg-[#1e1f2a] hover:bg-[#2e2f3a] transition-colors"
>
<p className="text-[#d4a843] text-xs font-medium truncate">{found.name}</p>
<p className="text-[#6b7280] text-xs mt-0.5">📍 {found.city} cliquer pour zoomer</p>
</button>
)}
</div>
);
}
+66
View File
@@ -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 (
<div className="space-y-1">
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="w-full h-8"
aria-hidden="true"
>
{buckets.map((count, i) => {
const h = Math.max(1, (count / max) * H);
return (
<rect
key={i}
x={i * barW + gap / 2}
y={H - h}
width={barW - gap}
height={h}
fill="#d4a843"
opacity={0.25 + 0.75 * (count / max)}
rx={0.5}
/>
);
})}
</svg>
<div className="flex justify-between text-[10px] text-[#4b5563]">
<span>{periodDays === 1 ? '0h' : 'J-' + periodDays}</span>
<span>{periodDays === 1 ? 'maintenant' : 'aujourd\'hui'}</span>
</div>
</div>
);
}
+15 -8
View File
@@ -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<PeriodStats | null>(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 <span className="text-[#d4a843]">Ğ1</span> sur une carte mondiale.
</p>
{/* Period label */}
<p className="text-[#4b5563] text-xs border-t border-[#1e1f2a] pt-3">
{animationLabel
? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></>
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
}
</p>
{/* Period label + sparkline */}
<div className="border-t border-[#1e1f2a] pt-3 space-y-2">
<p className="text-[#4b5563] text-xs">
{animationLabel
? <><span className="text-[#d4a843]"></span> <span className="text-[#d4a843]">{animationLabel}</span></>
: <>Période : <span className="text-[#6b7280]">{periodLabel}</span></>
}
</p>
{!animationLabel && allTimestamps.length > 0 && (
<Sparkline timestamps={allTimestamps} periodDays={periodDays} />
)}
</div>
{/* ---- Vue HEATMAP ---- */}
{viewMode === 'heatmap' && (