diff --git a/releases/v1.5.0.md b/releases/v1.5.0.md new file mode 100644 index 0000000..2baf758 --- /dev/null +++ b/releases/v1.5.0.md @@ -0,0 +1,22 @@ +## What's Changed + +### Nouvelles fonctionnalités + +- Indicateurs de statut en temps réel pour SubSquid et Cesium+ dans le panneau latéral (vert / jaune / rouge + latence) +- Bannière d'alerte automatique si un service devient inaccessible +- Configuration des endpoints à chaud : choisir parmi les nœuds publics connus, tester la latence en live, ou saisir une URL personnalisée — sans rechargement de page +- Persistance de la configuration dans `localStorage` + +### Améliorations + +- InfoPanel mis à jour : ajout des sections *Overlay Dividende Universel* et *Statut des services* + +### Détails techniques + +- Nouveau `src/services/EndpointConfig.ts` — gestion des URLs actives et des nœuds connus +- Nouveau `src/hooks/useServiceStatus.ts` — ping parallèle toutes les 30 s avec timeout 8 s via `AbortController` +- Nouveau `src/components/ServiceStatusDots.tsx` et `EndpointPopover.tsx` +- `SubsquidAdapter` et `CesiumAdapter` lisent l'URL active à chaque appel (plus de constante figée à l'import) +- Aucune nouvelle dépendance npm + +**Full Changelog**: https://git.syoul.fr/geoflux/compare/v1.4.1...v1.5.0 diff --git a/releases/v2.0.0-poisson-davril.md b/releases/v2.0.0-poisson-davril.md new file mode 100644 index 0000000..d279092 --- /dev/null +++ b/releases/v2.0.0-poisson-davril.md @@ -0,0 +1,118 @@ +:sparkles: **GéoFlux v2.0.0 — "Superposition"** + +> **Release du 1er avril 2026** · branche `main` · tag `v2.0.0` +> Nécessite Node 20+. + +--- + +Bonjour à toutes et tous, + +Nous sommes heureux d'annoncer la sortie de **GéoFlux v2.0.0**, la plus grande mise à jour depuis la v1.0.0, et probablement depuis l'invention de la monnaie libre. Cette version introduit la navigation historique depuis la genèse de la Ğ1, la géolocalisation quantique relative, la prise en charge des comptes non localisés par activation quantique passive, et l'intégration des transactions par chant cantique (RFC 4871-bis). + +--- + +:new: **Nouvelles fonctionnalités** + +--- + +:atom_symbol: **Géolocalisation quantique relative (GQR)** + +Suite à plusieurs signalements d'utilisateurs affirmant que les coordonnées GPS fournies par Cesium+ étaient *"imposées par les satellites gouvernementaux"*, GéoFlux propose désormais un mode de géolocalisation alternatif basé sur la position quantique de chaque nœud dans le graphe de transactions. + +Le principe : si Alice envoie régulièrement des Ğ1 à Bob, et que Bob est à Bordeaux, alors Alice est probablement à Bordeaux aussi — ou dans un état de superposition jusqu'à ce qu'on clique dessus. Précision déclarée : ±40 km. Précision observée : ±40 km dans 34% des cas, *"quelque part en France"* dans les 66% restants, ce qui reste supérieur à rien. + +Activable dans **Paramètres → Confidentialité → *"Je préfère ne pas confier mes coordonnées à l'État quantique mondial"***. +Désactivé par défaut, parce que nous, personnellement, faisons confiance aux satellites. + +--- + +:ghost: **Activation quantique passive (AQP) des comptes non localisés** + +Les comptes sans profil Cesium+ représentaient **41% du graphe** et apparaissaient comme des nœuds gris sans position. C'est techniquement correct mais visuellement déprimant. + +Leur position est désormais inférée par triangulation stochastique à partir de leurs cinq plus proches voisins transactionnels. L'algorithme converge en deux itérations. Quand il ne converge pas, le compte est placé à **Montpellier**, car c'est là qu'il y a le plus de membres Ğ1, et aussi parce qu'il faut bien choisir quelque chose. + +Les nœuds AQP s'affichent avec un **contour pointillé** indiquant qu'ils sont *"quantiquement probables"* et non *"classiquement certains"*. La distinction philosophique est réelle. La distinction visuelle est subtile. + +--- + +:musical_note: **Transactions par chant cantique (PTCC — RFC 4871-bis)** + +Le RFC 4871-bis, adopté lors du dernier HackMeet de Cluny, définit un mécanisme d'émission de transaction par modulation vocale : un chant grégorien de **trois strophes minimum**, entonné à portée Bluetooth d'un autre membre, déclenche un envoi de **1 Ğ1** signé par l'empreinte spectrale du chanteur. + +GéoFlux v2.0.0 indexe et affiche ces transactions : arcs de couleur **ambre**, icône ♩. Le StatsPanel expose le *"Flux vocal agrégé (Ğ1/strophe)"*. + +> **État du réseau à ce jour :** 14 transactions cantiques détectées depuis la genèse. 12 proviennent d'un seul nœud, situé à l'abbaye de Sénanque. Ce nœud n'a pas de profil Cesium+ et a été placé à **Cavaillon** par AQP, ce qui est raisonnablement juste. + +:warning: Une transaction émise en mode mineur était interprétée comme un remboursement (bug #127). Résolu. Le mode mineur est désormais traité comme le mode majeur sur le plan comptable, sans préjuger de son contenu émotionnel. + +--- + +:clock3: **Navigation historique depuis la genèse (Phase 1–3)** + +- Sélecteur de **plage libre** (`from` / `to`) en remplacement du menu "derniers N jours" +- **Granularité automatique** selon la durée (jour → semaine → mois → trimestre) +- **Timeline interactive** couvrant mars 2017 → aujourd'hui, avec volumes trimestriels en arrière-plan +- Affichage en **cercles proportionnels** pour les fenêtres > 30 jours +- **Cache IndexedDB** — périodes passées mises en cache 24 h. Pour les transactions cantiques, la tonalité dominante de chaque strophe est également stockée. Cette information n'est utilisée nulle part mais semblait dommage de jeter. + +La timeline révèle clairement qu'il ne s'est presque rien passé en 2020. Nous ne faisons aucun commentaire. + +--- + +:wrench: **Corrections de bugs** + +- **#88** — Timeout systématique sur les périodes > 180 jours. Résolu par agrégation côté client. +- **#91** — Crash mémoire Firefox 124 sur > 8 000 transactions. Résolu. Firefox 124 n'est plus supporté non plus, mais c'est une coïncidence. +- **#103** — Le sélecteur de période ne revenait pas à sa valeur après annulation. Ce bug existait depuis la v0.9.0 ; personne ne l'avait signalé, ce qui nous en dit long sur l'utilisation du bouton Annuler. +- **#107** — Arcs invisibles au zoom 3 sur Safari mobile. Résolu. Safari n'a pas été remercié pour sa coopération. +- **#112** — Double `flyTo` en cas de clic rapide sur une ville. Résolu par un debounce de 200 ms. L'app ignore désormais délibérément vos clics pendant 200 ms. C'est de l'UX. +- **#124** — Les comptes placés à Montpellier *avant* l'AQP y restaient après convergence. Résolu. Montpellier reste néanmoins le repli. +- **#127** — Transaction cantique en mode mineur interprétée comme un remboursement. Résolu sur le plan comptable, pas sur le plan musical. + +--- + +:warning: **Breaking changes** + +[details="Voir les breaking changes"] + +**`useGeoFlux()`** ne retourne plus `periodDays: number` mais `period: { from: Date; to: Date; granularity: Granularity }`. + +**`DataService.fetchData(periodDays)`** → **`fetchData(from: Date, to: Date)`**. L'ancienne signature lève une erreur TypeScript. TypeScript a, comme toujours, raison. + +**`GeoNode`** inclut un nouveau champ `localizationMode: 'classic' | 'quantum' | 'canticle'`. La valeur `'canticle'` s'applique aux nœuds dont la seule source de localisation connue est une transaction cantique reçue. Il y en a deux. + +**`localStorage['geoflux-cache']`** est supprimé au démarrage. Les données mises en cache avant la v2.0.0 ne sont pas migrées, pour des raisons techniques tout à fait valables que nous n'exposerons pas ici. + +[/details] + +--- + +:no_entry: **Ce qui n'est pas dans cette version** + +- **Transactions par tambour chamanique** (RFC 4871-ter) — encore en draft, en relecture à Tübingen depuis dix-huit mois +- **Regroupement par pays au zoom ≤ 4** — `countryCode` vaut `null` pour 34% des nœuds, ce qui aurait fait de `"null"` le cinquième pays par volume de transactions Ğ1 +- **Export CSV** — décalé en v2.1, comme en v1.9, v1.7, et v1.5 +- **Mode sombre** +- **Un logo** + +--- + +:bar_chart: **Statistiques** + +``` +Commits depuis v1.4.1 : 47 +Lignes ajoutées / supprimées : 1847 / 412 +Transactions cantiques (genèse) : 14 +Nœuds à Montpellier avant AQP : 38 +Nœuds à Montpellier après AQP : 12 +Nœuds réellement à Montpellier : ? (non déterminable avec certitude) +Durée estimée : 3 semaines +Durée réelle : 11 semaines +``` + +6 de ces 11 semaines ont été consacrées à la GQR — fonctionnalité qui était initialement **une blague dans un ticket GitHub** et qui s'est retrouvée en production par un enchaînement d'événements que personne ne sait exactement reconstituer. + +--- + +*L'équipe GéoFlux* diff --git a/src/App.tsx b/src/App.tsx index b92e8a2..52a282d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,9 @@ import { PeriodSelector } from './components/PeriodSelector'; import { HeatMap } from './components/HeatMap'; import { FlowMap } from './components/FlowMap'; import { AnimationPlayer } from './components/AnimationPlayer'; -import { fetchData } from './services/DataService'; -import type { PeriodStats } from './services/DataService'; +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'; @@ -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,36 @@ 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 [showMembers, setShowMembers] = useState(false); + const [memberCities, setMemberCities] = useState([]); + 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); @@ -43,6 +67,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; @@ -71,7 +120,7 @@ export default function App() { const interval = setInterval(() => load(false), 30_000); return () => { cancelled = true; clearInterval(interval); }; - }, [periodDays]); + }, [periodDays, endpointVersion]); // Stats heatmap sur la fenêtre courante en mode animation const visibleStats: PeriodStats | null = animation.active @@ -101,6 +150,8 @@ export default function App() { viewMode, flowStats, focusCity, + allTimestamps, + onEndpointChange: () => setEndpointVersion((v) => v + 1), }; return ( @@ -111,7 +162,10 @@ export default function App() { {/* Map area */}
{viewMode === 'heatmap' ? ( - + ) : ( + {/* Barre de recherche identité */} +
+ { + setViewMode('flow'); + setFocusCity(city); + }} + /> +
+ + {/* Toggle overlay membres DU */} + + {/* Period selector — floating over map */}
void; + onSaved: () => void; +} + +interface TestResult { + url: string; + state: 'testing' | 'ok' | 'slow' | 'error'; + latencyMs: number | null; +} + +const LABELS = { subsquid: 'SubSquid', cesium: 'Cesium+' }; + +export function EndpointPopover({ service, onClose, onSaved }: Props) { + const currentUrl = service === 'subsquid' ? getSubsquidUrl() : getCesiumUrl(); + const knownNodes = service === 'subsquid' ? KNOWN_SUBSQUID_NODES : KNOWN_CESIUM_NODES; + + const [inputUrl, setInputUrl] = useState(currentUrl); + const [testResults, setTestResults] = useState>(new Map()); + + const runTest = async (url: string) => { + setTestResults((prev) => new Map(prev).set(url, { url, state: 'testing', latencyMs: null })); + try { + const ms = await testEndpoint(service, url); + setTestResults((prev) => + new Map(prev).set(url, { url, state: ms < 2000 ? 'ok' : 'slow', latencyMs: ms }) + ); + } catch { + setTestResults((prev) => new Map(prev).set(url, { url, state: 'error', latencyMs: null })); + } + }; + + const handleSave = () => { + const trimmed = inputUrl.trim(); + if (!trimmed) return; + if (service === 'subsquid') setSubsquidUrl(trimmed); + else setCesiumUrl(trimmed); + onSaved(); + onClose(); + }; + + const dot = (state: TestResult['state']) => { + if (state === 'testing') return ; + if (state === 'ok') return ; + if (state === 'slow') return ; + return ; + }; + + return createPortal( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ + {/* Header */} +
+

+ Configurer {LABELS[service]} +

+ +
+ + {/* Nœuds connus */} +
+

Nœuds connus

+ {knownNodes.map((node) => { + const result = testResults.get(node.url); + const isActive = inputUrl === node.url; + return ( +
setInputUrl(node.url)} + > +
+

{node.label}

+

{node.url}

+
+
+ {result && ( + + {dot(result.state)} + {result.latencyMs !== null && ` ${result.latencyMs} ms`} + + )} + +
+
+ ); + })} +
+ + {/* URL personnalisée */} +
+

URL personnalisée

+
+ setInputUrl(e.target.value)} + placeholder={service === 'subsquid' ? 'https://…/v1/graphql' : 'https://…'} + className="flex-1 bg-[#0a0b0f] border border-[#2e2f3a] rounded-xl px-3 py-2 text-white text-sm font-mono placeholder-[#2e2f3a] focus:outline-none focus:border-[#d4a843]/60 transition-colors" + /> + +
+ {(() => { + const result = testResults.get(inputUrl.trim()); + if (!result || knownNodes.some((n) => n.url === inputUrl.trim())) return null; + return ( +

+ {dot(result.state)} + {result.state === 'testing' && ' Test en cours…'} + {result.state === 'ok' && ` OK · ${result.latencyMs} ms`} + {result.state === 'slow' && ` Lent · ${result.latencyMs} ms`} + {result.state === 'error' && ' Inaccessible'} +

+ ); + })()} +
+ + {/* Actions */} +
+ + +
+
+
, + document.body + ); +} diff --git a/src/components/HeatMap.tsx b/src/components/HeatMap.tsx index 3229410..6eef60b 100644 --- a/src/components/HeatMap.tsx +++ b/src/components/HeatMap.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet.heat'; import type { Transaction } from '../data/mockData'; +import type { MemberCity } from '../services/DataService'; // Leaflet default marker fix (Vite asset pipeline) import iconUrl from 'leaflet/dist/images/marker-icon.png'; @@ -10,6 +11,7 @@ L.Icon.Default.mergeOptions({ iconUrl, shadowUrl: iconShadowUrl }); interface HeatMapProps { transactions: Transaction[]; + memberCities?: MemberCity[]; } const HEAT_OPTIONS: L.HeatMapOptions = { @@ -29,10 +31,11 @@ const HEAT_OPTIONS: L.HeatMapOptions = { }, }; -export function HeatMap({ transactions }: HeatMapProps) { +export function HeatMap({ transactions, memberCities = [] }: HeatMapProps) { const containerRef = useRef(null); const mapRef = useRef(null); const heatRef = useRef(null); + const memberLayerRef = useRef(null); // Two img overlays that cross-fade between each other. // The canvas opacity is NEVER touched — it stays at leaflet's default. const prevRef = useRef(null); @@ -59,9 +62,11 @@ export function HeatMap({ transactions }: HeatMapProps) { L.control.zoom({ position: 'bottomright' }).addTo(map); const heat = L.heatLayer([], HEAT_OPTIONS).addTo(map); + const memberLayer = L.layerGroup().addTo(map); mapRef.current = map; heatRef.current = heat; + memberLayerRef.current = memberLayer; // Pendant zoom/pan : cache les overlays → le canvas live est visible directement. // Après zoom/pan : resynchronise le snapshot sur le canvas redesssiné. @@ -100,9 +105,33 @@ export function HeatMap({ transactions }: HeatMapProps) { map.remove(); mapRef.current = null; heatRef.current = null; + memberLayerRef.current = null; }; }, []); + // Overlay membres DU : cercles proportionnels au nombre de membres par ville + useEffect(() => { + const layer = memberLayerRef.current; + if (!layer) return; + layer.clearLayers(); + if (memberCities.length === 0) return; + + const maxCount = Math.max(...memberCities.map((c) => c.count), 1); + for (const city of memberCities) { + const radius = 4 + Math.sqrt(city.count / maxCount) * 18; + L.circleMarker([city.lat, city.lng], { + radius, + color: '#00c853', + fillColor: '#00c853', + fillOpacity: 0.18, + weight: 1.5, + opacity: 0.7, + }) + .bindTooltip(`${city.city}
${city.count} membre${city.count > 1 ? 's' : ''}`, { sticky: true }) + .addTo(layer); + } + }, [memberCities]); + // Crossfade: two img overlays swap roles each frame. // Canvas is never hidden — we only read its pixel data via toDataURL(). useEffect(() => { diff --git a/src/components/InfoPanel.tsx b/src/components/InfoPanel.tsx index 82563c5..96399de 100644 --- a/src/components/InfoPanel.tsx +++ b/src/components/InfoPanel.tsx @@ -83,11 +83,42 @@ export function InfoPanel({ onClose }: InfoPanelProps) { +
+ + frame précédente / suivante · + Espace lecture / pause. + + + H basculer Heatmap ↔ Flux · + Échap quitter l'animation ou fermer ce panneau. + +
+ +
+ + Le bouton (à gauche de la carte) accepte un nom d'identité Ğ1 + (ex : "Alice") ou une clé publique g1…. + Il bascule automatiquement en vue Flux et met la ville en focus. + +
+ +
+ + L'URL reflète l'état courant : période, vue, ville sélectionnée. + Partager l'URL restitue exactement la même configuration. + Exemple : ?period=30&view=flow&city=Paris + +
+
Volume total en Ğ1, nombre de transactions, top émetteurs et receveurs, répartition géographique. Se met à jour en temps réel et pendant l'animation. + + Mini-graphique d'activité journalière affiché sous la période, + calculé depuis les timestamps déjà en mémoire. + Le panneau est accessible via le bouton en haut à gauche. @@ -96,9 +127,25 @@ export function InfoPanel({ onClose }: InfoPanelProps) {
+
+ + Le bouton DU (à gauche de la carte) affiche en overlay les membres Ğ1 + actifs (WoT) ayant un profil Cesium+ géolocalisé. + Chaque point représente une ville avec des membres actifs. + +
+
- Données temps réel de la blockchain Ğ1v2, actualisées toutes les 30 secondes. + Données temps réel de la blockchain Ğ1v2 via SubSquid, actualisées toutes les 30 secondes. + Les profils de géolocalisation sont fournis par Cesium+. + + + Deux indicateurs en bas du panneau latéral affichent l'état de SubSquid et Cesium+ en temps réel + (vert OK ·{' '} + jaune lent ·{' '} + rouge inaccessible). + Un clic sur un indicateur permet de configurer ou changer l'endpoint.
diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..ada69b5 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -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(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/ServiceStatusDots.tsx b/src/components/ServiceStatusDots.tsx new file mode 100644 index 0000000..477b0fd --- /dev/null +++ b/src/components/ServiceStatusDots.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import type { ServiceStatus, ServiceState } from '../hooks/useServiceStatus'; +import { EndpointPopover } from './EndpointPopover'; + +interface Props { + subsquid: ServiceStatus; + cesium: ServiceStatus; + onEndpointChange: () => void; +} + +const STATE_COLOR: Record = { + checking: 'text-[#4b5563] animate-pulse', + ok: 'text-emerald-400', + slow: 'text-amber-400', + error: 'text-red-500', +}; + +function Dot({ status, label, onClick }: { status: ServiceStatus; label: string; onClick: () => void }) { + const latency = status.latencyMs !== null ? ` · ${status.latencyMs} ms` : ''; + const title = `${label} — ${STATUS_LABEL_FULL[status.state]}${latency}\n${status.url}`; + + return ( + + ); +} + +const STATUS_LABEL_FULL: Record = { + checking: 'Vérification…', + ok: 'Accessible', + slow: 'Réponse lente', + error: 'Inaccessible', +}; + +export function ServiceStatusDots({ subsquid, cesium, onEndpointChange }: Props) { + const [popover, setPopover] = useState<'subsquid' | 'cesium' | null>(null); + + const hasError = subsquid.state === 'error' || cesium.state === 'error'; + const erroredService = subsquid.state === 'error' ? 'SubSquid' : 'Cesium+'; + const erroredKey: 'subsquid' | 'cesium' = subsquid.state === 'error' ? 'subsquid' : 'cesium'; + + return ( + <> + {/* Bannière d'erreur */} + {hasError && ( +
+ + + {erroredService} inaccessible + + +
+ )} + + {/* Dots */} +
+ setPopover('subsquid')} /> + setPopover('cesium')} /> +
+ + {/* Popover de configuration */} + {popover && ( + setPopover(null)} + onSaved={onEndpointChange} + /> + )} + + ); +} 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..364ea64 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -1,6 +1,9 @@ import { useRef } from 'react'; import type { PeriodStats } from '../services/DataService'; import type { FlowStats } from '../data/arcData'; +import { Sparkline } from './Sparkline'; +import { ServiceStatusDots } from './ServiceStatusDots'; +import { useServiceStatus } from '../hooks/useServiceStatus'; interface StatsPanelProps { stats: PeriodStats | null; @@ -14,6 +17,8 @@ interface StatsPanelProps { flowStats?: FlowStats | null; focusCity?: string | null; onClose?: () => void; + onEndpointChange?: () => void; + allTimestamps?: number[]; } const MEDALS = ['🥇', '🥈', '🥉']; @@ -60,7 +65,8 @@ 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, onEndpointChange, className, allTimestamps = [] }: StatsPanelProps) { + const { subsquid, cesium, recheck } = useServiceStatus(); const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`; const prevStats = useRef(null); @@ -95,7 +101,11 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim Ğ1Flux v{__APP_VERSION__} -

Monnaie libre · Flux géo

+ { recheck(); onEndpointChange?.(); }} + />
{onClose && (