41 Commits

Author SHA1 Message Date
syoul ac168c3689 Merge branch 'dev'
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-28 11:52:27 +01:00
syoul 8f9a11c4e8 chore: bump version to 1.4.1
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:50:31 +01:00
syoul 63f50d5762 feat: géolocaliser les comptes non-membres via Cesium+
ci/woodpecker/push/woodpecker Pipeline was successful
Pour les fromId/toId absents du keyMap WoT, applique ss58ToDuniterKey
directement pour tenter un lookup Cesium+. Les non-membres ayant un
profil géolocalisé (ex: comptes portefeuille avec ville renseignée)
apparaissent désormais dans le flux animé.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:38:31 +01:00
syoul 9f3752b621 chore: merge dev → main v1.4.0
ci/woodpecker/push/woodpecker Pipeline was successful
- feat: bouton ℹ flottant isolé sous ☰ (mobile) / top-left (desktop)
- fix: supprimer label 'Vitesse' + bouton ✕ AnimationPlayer
- fix: couleur émetteurs rouge #e53935 (meilleur contraste vs or)
- fix: InfoPanel — dégradés or→rouge / or→vert documentés
- docs: features-roadmap.md (14 features planifiées)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:31:49 +01:00
syoul 6fc1705f6d chore: bump version to 1.4.0
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:31:36 +01:00
syoul 15807c7bcb fix: InfoPanel — couleur émetteurs rouge (dégradé or→rouge) + description dégradés
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:27:16 +01:00
syoul bac113e51b fix: couleur émetteurs #e53935 (rouge) au lieu de #ff6d00 (orange)
ci/woodpecker/push/woodpecker Pipeline was successful
Meilleur contraste avec la couleur neutre or #d4a843.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:22:45 +01:00
syoul 6d01c8d29e fix: supprimer bouton ✕ de l'AnimationPlayer (fermeture via ▶ Animer)
ci/woodpecker/push/woodpecker Pipeline was successful
Économise une ligne sur mobile. onClose retiré de l'interface et du JSX.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:19:52 +01:00
syoul 46b11710cc fix: supprimer label 'Vitesse' dans AnimationPlayer (gain de place mobile)
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:15:41 +01:00
syoul 78ede01d11 feat: déplacer bouton ℹ hors du PeriodSelector → bouton flottant isolé
ci/woodpecker/push/woodpecker Pipeline was successful
- Sous ☰ sur mobile (top-16 left-4), top-4 left-4 sur desktop
- PeriodSelector : suppression prop onInfo + bouton ℹ intégré

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:12:24 +01:00
syoul 70de7e4c06 chore: merge dev → main v1.3.2
ci/woodpecker/push/woodpecker Pipeline was successful
- fix: bouton Clusters bottom-44 mobile / bottom-24 desktop
- fix: bouton Clusters bottom-36/32 (iterations précédentes)
- fix: plan historique-genesis.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:08:09 +01:00
syoul 65f26e2b58 chore: bump version to 1.3.2
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:07:52 +01:00
syoul 104949427c fix: bouton Clusters bottom-44 mobile / bottom-24 desktop
ci/woodpecker/push/woodpecker Pipeline was successful
Sur mobile réel, la police forcée à 16px fait wrapper les contrôles
AnimationPlayer en 2 lignes (~165px). bottom-44 (176px) sur mobile,
bottom-24 (96px) sur sm+ où les contrôles ne wrappent pas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 13:05:01 +01:00
syoul 9ec95dfc91 fix: bouton Clusters bottom-36 — dépasse le bord supérieur de l'AnimationPlayer
ci/woodpecker/push/woodpecker Pipeline was successful
Player: bottom-4 + ~114px hauteur → bord sup à ~130px.
bottom-32 (128px) chevauchait de 2px. bottom-36 (144px) donne 14px de marge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:52:51 +01:00
syoul ab1bad2209 chore: merge dev → main v1.3.1
ci/woodpecker/push/woodpecker Pipeline was successful
- fix: retirer mention Mock de l'InfoPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:39:32 +01:00
syoul 3dbd8704ff chore: bump version to 1.3.1
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:39:22 +01:00
syoul b0104207c4 fix: retirer la mention Mock de l'InfoPanel (détail interne)
ci/woodpecker/push/woodpecker Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:39:13 +01:00
syoul 00f0602c61 chore: merge dev → main v1.3.0
ci/woodpecker/push/woodpecker Pipeline was successful
- feat: clusters vue Flux (toggle Clusters/Villes, popup balance)
- feat: % Tx géoloc. dans la barre de contrôles
- feat: bouton ℹ + modale InfoPanel (toutes les fonctionnalités)
- fix: layout mobile (bottom drawer, badge focus, AnimationPlayer)
- fix: bouton Clusters visible en mode animation (z-[1002], bottom-32)
- fix: pipeline CI — .dockerignore, syft v1.42.3 pinné, trivy 0.69.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:35:56 +01:00
syoul 4821dab6e6 chore: bump version to 1.3.0
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:35:41 +01:00
syoul eb4e693f3c fix: nom de l'app Ğ1Flux (et non ĞéoFlux) dans InfoPanel
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:32:46 +01:00
syoul 810c815706 feat: bouton ℹ + modale InfoPanel décrivant toutes les fonctionnalités
ci/woodpecker/push/woodpecker Pipeline was successful
- InfoPanel : modale avec overlay, sections Vues / Clusters / Période /
  Animation / Statistiques / Source, composants Section/Feature/Kbd
- PeriodSelector : ajout prop onInfo + bouton ℹ en fin de barre
- App.tsx : état infoOpen, onInfo → setInfoOpen(true), rendu InfoPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:28:51 +01:00
syoul 0e040510af fix: bouton Clusters bottom-32 — juste au-dessus de l'AnimationPlayer
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:22:12 +01:00
syoul ac974fb8a0 fix: bouton Clusters z-[1002] — visible au-dessus de l'AnimationPlayer
ci/woodpecker/push/woodpecker Pipeline was successful
AnimationPlayer z-[1001] couvrait le bouton z-[600] sur mobile
(player ~130px, bouton à bottom-20/80px).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:18:51 +01:00
syoul 666dc99989 fix: sbom-generate — version syft hardcodée dans l'URL (env non expansé)
ci/woodpecker/push/woodpecker Pipeline was successful
Les variables environment: Woodpecker ne sont pas expansées dans les
commandes shell YAML. Version v1.42.3 écrite directement dans l'URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:15:20 +01:00
syoul c02f207b6c fix: sbom-generate — alpine + syft pinné via GitHub releases
ci/woodpecker/push/woodpecker Pipeline failed
anchore/syft:vX est distroless (pas de /bin/sh), incompatible avec
les commands Woodpecker. Retour sur alpine:3.20 avec téléchargement
direct du tarball v1.42.3 depuis GitHub releases (pas install.sh
qui tire latest).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:14:16 +01:00
syoul 77f5f44758 fix: sbom-generate — image anchore/syft officielle + versions pinnées
ci/woodpecker/push/woodpecker Pipeline failed
- anchore/syft:v1.42.3 (remplace alpine:3.20 + curl-install latest)
- aquasec/trivy:0.69.3 (remplace :latest)
- Source explicite docker:g1flux:latest pour éviter le bug
  d'auto-détection Syft dans un container Woodpecker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:12:58 +01:00
syoul c54e76bb04 chore: ajouter .dockerignore pour accélérer le build Docker
ci/woodpecker/push/woodpecker Pipeline failed
Exclut node_modules, dist et docs du contexte de build.
Sans ce fichier, les 208 répertoires de node_modules étaient
transférés inutilement au démon Docker à chaque build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:07:42 +01:00
syoul b7e8bade97 fix: bouton Clusters bottom-20 (au-dessus AnimationPlayer) + badge géo en blanc
ci/woodpecker/push/woodpecker Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:59:16 +01:00
syoul 64682ea773 fix: déplacer bouton Clusters en bas à gauche (évite zoom + AnimationPlayer)
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:56:09 +01:00
syoul 53b1e9b399 fix: remonter le bouton Clusters pour éviter chevauchement avec zoom Leaflet
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:53:44 +01:00
syoul c51bb251e3 feat: bouton toggle Clusters / Villes dans la vue Flux
ci/woodpecker/push/woodpecker Pipeline was successful
Permet de basculer entre la vue groupée (clustering glouton, défaut)
et la vue individuelle (une ville = un nœud). Le bouton est positionné
en bas à droite de la carte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:49:06 +01:00
syoul 851dc46394 fix: corriger layout desktop — StatsPanel w-72 fixe, w-full dans drawer mobile
ci/woodpecker/push/woodpecker Pipeline was successful
Remplace w-full lg:w-72 h-full (qui cassait les écrans 640-1023px) par
un prop className : w-72 shrink-0 par défaut (desktop), w-full flex-1
min-h-0 dans le drawer mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:44:23 +01:00
syoul 786bf30a7b feat: popup liste des villes + balance au clic sur un cluster
ci/woodpecker/push/woodpecker Pipeline was successful
Affiche un panneau flottant au clic sur un nœud : liste des villes
du cluster triées par |balance|, balance nette colorée (orange/teal).
Se ferme sur déplacement/zoom de la carte ou via ✕.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:36:20 +01:00
syoul 839acf8aa8 fix: libellé badge géo → "XX% Tx géoloc."
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:26:50 +01:00
syoul ffe09ea44a feat: afficher le % géolocalisé dans la barre de contrôles
ci/woodpecker/push/woodpecker Pipeline was successful
Ajoute un badge "XX% géo" à droite du bouton Flux/Heatmap dans
PeriodSelector, mis à jour à chaque frame d'animation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:24:03 +01:00
syoul e4eb02560a Merge dev → main : v1.2.0
ci/woodpecker/push/woodpecker Pipeline was successful
- feat: coloration nœuds par balance nette (orange émetteur / vert récepteur)
- feat: clustering géographique des villes dans la vue Flux
- feat: adaptation mobile — drawer bottom + layout responsive
2026-03-24 11:11:27 +01:00
syoul 8e208d02ab chore: bump version to 1.2.0
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:11:05 +01:00
syoul 16cebb6ec9 feat: adaptation mobile — drawer bottom + layout responsive
ci/woodpecker/push/woodpecker Pipeline was successful
Sur smartphone (< 640px) : panneau stats masqué par défaut, accessible
via un bottom drawer animé (bouton ☰). PeriodSelector passe en flex-wrap
avec padding tactile 44px. AnimationPlayer s'adapte à la largeur écran.
Badge ville focus affiché directement sur la carte en mode mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:07:48 +01:00
syoul b6cb0af722 feat: clustering géographique des villes dans la vue Flux
ci/woodpecker/push/woodpecker Pipeline was successful
Regroupe les villes proches visuellement (CLUSTER_RADIUS = 38px) en un
seul nœud dont la couleur reflète la balance nette agrégée du groupe.
Affiche +N à l'intérieur des cercles multi-villes. Les arcs intra-cluster
sont ignorés. Le clustering se recalcule dynamiquement à chaque zoom/pan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:20:00 +01:00
syoul 78b4762c88 feat: coloration des nœuds par balance nette (orange émetteur / vert récepteur)
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:55:08 +01:00
syoul 5978ddfed3 release: v1.1.0
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-24 00:33:12 +01:00
11 changed files with 534 additions and 96 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
docs-plan
docs-bugs
docs-syoul
*.md
.env*
+6 -4
View File
@@ -45,20 +45,22 @@ steps:
# Etape 4a : Generation SBOM (Syft) depuis l'image locale
# NOTE: volumes + pas de from_secret : compatible
# Utilise l'image officielle anchore/syft pour eviter le bug d'auto-detection
# de container (signal Go imprime en adresse memoire sur alpine + curl install)
- name: sbom-generate
image: alpine:3.20
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
- apk add --no-cache curl tar
- curl -sSfL "https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_linux_amd64.tar.gz" | tar xz -C /usr/local/bin syft
- mkdir -p .reports
- syft g1flux:latest -o cyclonedx-json --file .reports/sbom-app.cyclonedx.json
- syft packages docker:g1flux:latest -o cyclonedx-json=.reports/sbom-app.cyclonedx.json
- echo "SBOM genere"
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
- name: sbom-scan
image: aquasec/trivy:latest
image: aquasec/trivy:0.69.3
volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy
commands:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "g1flux",
"private": true,
"version": "1.1.0",
"version": "1.4.1",
"type": "module",
"scripts": {
"dev": "vite",
+78 -16
View File
@@ -11,6 +11,8 @@ 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';
export default function App() {
const [periodDays, setPeriodDays] = useState(7);
@@ -25,6 +27,9 @@ export default function App() {
const [allTimestamps, setAllTimestamps] = useState<number[]>([]);
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
const [focusCity, setFocusCity] = useState<string | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const isMobile = useMediaQuery('(max-width: 639px)');
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
@@ -86,20 +91,22 @@ export default function App() {
[arcs, animation.visibleArcs, animation.active],
);
const statsPanelProps = {
stats: visibleStats,
loading,
periodDays,
source,
currentUD,
animationLabel: animation.active ? (animation.currentFrame?.label ?? undefined) : undefined,
viewMode,
flowStats,
focusCity,
};
return (
<div className="flex h-svh w-full overflow-hidden bg-[#0a0b0f] text-white">
{/* Side panel */}
<StatsPanel
stats={visibleStats}
loading={loading}
periodDays={periodDays}
source={source}
currentUD={currentUD}
animationLabel={animation.active ? (animation.currentFrame?.label ?? undefined) : undefined}
viewMode={viewMode}
flowStats={flowStats}
focusCity={focusCity}
/>
{/* Side panel — desktop uniquement */}
{!isMobile && <StatsPanel {...statsPanelProps} />}
{/* Map area */}
<div className="relative flex-1 min-w-0">
@@ -113,8 +120,28 @@ export default function App() {
/>
)}
{/* 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>
{/* Period selector — floating over map */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[1000]">
<div className={`absolute top-4 z-[1000] ${isMobile ? 'left-16 right-4' : 'left-1/2 -translate-x-1/2'}`}>
<PeriodSelector
value={periodDays}
onChange={handlePeriodChange}
@@ -122,11 +149,22 @@ export default function App() {
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>
{/* Transaction count + source badge (masqués en mode animation) */}
{!loading && !animation.active && (
{/* 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
@@ -154,7 +192,6 @@ export default function App() {
onPlay={animation.play}
onPause={animation.pause}
onSpeedChange={animation.setSpeed}
onClose={animation.deactivate}
/>
)}
@@ -168,6 +205,31 @@ export default function App() {
</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>
);
}
+4 -15
View File
@@ -9,7 +9,6 @@ interface AnimationPlayerProps {
onPlay: () => void;
onPause: () => void;
onSpeedChange: (s: 1 | 2 | 4) => void;
onClose: () => void;
}
export function AnimationPlayer({
@@ -21,13 +20,12 @@ export function AnimationPlayer({
onPlay,
onPause,
onSpeedChange,
onClose,
}: AnimationPlayerProps) {
const frame = frames[currentIndex];
return (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,90vw)]">
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-5 py-3 flex flex-col gap-2.5 shadow-xl">
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-[1001] w-[min(640px,calc(100vw-1rem))]">
<div className="bg-[#0a0b0f]/90 backdrop-blur-sm border border-[#2e2f3a] rounded-2xl px-4 py-3 flex flex-col gap-2.5 shadow-xl">
{/* Frame label + position */}
<div className="flex items-center justify-between">
@@ -50,7 +48,7 @@ export function AnimationPlayer({
/>
{/* Controls row */}
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-y-2">
{/* Playback buttons */}
<div className="flex items-center gap-1">
@@ -78,8 +76,7 @@ export function AnimationPlayer({
{/* Speed selector */}
<div className="flex items-center gap-1">
<span className="text-[#4b5563] text-xs mr-1">Vitesse</span>
{([1, 2, 4] as const).map((s) => (
{([1, 2, 4] as const).map((s) => (
<button
key={s}
onClick={() => onSpeedChange(s)}
@@ -94,14 +91,6 @@ export function AnimationPlayer({
))}
</div>
{/* Close */}
<button
onClick={onClose}
className="text-[#4b5563] hover:text-white transition-colors px-2 py-1 text-sm ml-2"
title="Quitter l'animation"
>
</button>
</div>
</div>
</div>
+245 -48
View File
@@ -1,5 +1,27 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import L from 'leaflet';
/** Interpolation RGB linéaire entre deux couleurs hex, t ∈ [0, 1]. */
function lerpColor(hex1: string, hex2: string, t: number): string {
const parse = (h: string) => [
parseInt(h.slice(1, 3), 16),
parseInt(h.slice(3, 5), 16),
parseInt(h.slice(5, 7), 16),
];
const [r1, g1, b1] = parse(hex1);
const [r2, g2, b2] = parse(hex2);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
const COLOR_NEUTRAL = '#d4a843'; // or Ğ1
const COLOR_NEG = '#e53935'; // rouge vif
const COLOR_POS = '#00c853'; // vert vif
const NEUTRAL_THRESHOLD = 0.05; // ±5 % → couleur neutre
const CLUSTER_RADIUS = 38; // pixels — distance max pour regrouper deux villes
import type { TransactionArc } from '../data/arcData';
import { buildCorridors } from '../data/arcData';
@@ -17,8 +39,9 @@ interface FlowMapProps {
export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const [mapReady, setMapReady] = useState(false);
const [tick, setTick] = useState(0); // incrémenté sur moveend/zoomend → re-render
const [mapReady, setMapReady] = useState(false);
const [tick, setTick] = useState(0);
const [clustered, setClustered] = useState(true);
// Initialisation Leaflet
useEffect(() => {
@@ -71,7 +94,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
return map;
}, [corridors]);
// Projection SVG (recalculée sur chaque tick, changement d'arcs ou de focusCity)
// Projection SVG + clustering (recalculée sur chaque tick, changement d'arcs ou de focusCity)
const svgElements = useMemo(() => {
const m = mapRef.current;
if (!m || !mapReady) return null;
@@ -81,15 +104,105 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
return { x: p.x, y: p.y };
};
const maxVol = Math.max(...corridors.map(c => c.totalVolume), 1);
const maxNodeVol = Math.max(...[...cityNodes.values()].map(c => c.emitted + c.received), 1);
// --- 1. Projeter toutes les villes en pixels, triées par volume desc ---
type CityPx = {
name: string; lat: number; lng: number;
x: number; y: number;
emitted: number; received: number; vol: number;
};
const cityList: CityPx[] = [...cityNodes.entries()].map(([name, d]) => {
const p = proj(d.lat, d.lng);
return { name, lat: d.lat, lng: d.lng, x: p.x, y: p.y, emitted: d.emitted, received: d.received, vol: d.emitted + d.received };
}).sort((a, b) => b.vol - a.vol);
// ---- Arcs ----
const arcElems = corridors.map((c, idx) => {
const p1 = proj(c.fromLat, c.fromLng);
const p2 = proj(c.toLat, c.toLng);
// --- 2. Clustering glouton par distance pixel (ou 1 ville = 1 cluster) ---
interface Cluster {
cx: number; cy: number;
lat: number; lng: number;
totalVol: number;
emitted: number; received: number;
cities: Set<string>;
}
const clusters: Cluster[] = [];
const cityClusterIdx = new Map<string, number>();
for (const city of cityList) {
let bestIdx = -1;
if (clustered) {
let bestDist = Infinity;
for (let i = 0; i < clusters.length; i++) {
const cl = clusters[i];
const dx = city.x - cl.cx;
const dy = city.y - cl.cy;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < CLUSTER_RADIUS && d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
}
if (bestIdx === -1) {
clusters.push({
cx: city.x, cy: city.y,
lat: city.lat, lng: city.lng,
totalVol: city.vol,
emitted: city.emitted, received: city.received,
cities: new Set([city.name]),
});
cityClusterIdx.set(city.name, clusters.length - 1);
} else {
const cl = clusters[bestIdx];
const newVol = cl.totalVol + city.vol;
cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol;
cl.cy = (cl.cy * cl.totalVol + city.y * city.vol) / newVol;
cl.totalVol = newVol;
cl.emitted += city.emitted;
cl.received += city.received;
cl.cities.add(city.name);
cityClusterIdx.set(city.name, bestIdx);
}
}
// --- 3. Agréger les corridors en arcs inter-clusters ---
interface ClusterArc {
fromIdx: number; toIdx: number;
totalVolume: number; count: number;
}
const clArcMap = new Map<string, ClusterArc>();
for (const c of corridors) {
const fi = cityClusterIdx.get(c.fromCity);
const ti = cityClusterIdx.get(c.toCity);
if (fi === undefined || ti === undefined || fi === ti) continue; // intra-cluster → ignoré
const key = `${fi}||${ti}`;
if (!clArcMap.has(key)) clArcMap.set(key, { fromIdx: fi, toIdx: ti, totalVolume: 0, count: 0 });
const ca = clArcMap.get(key)!;
ca.totalVolume += c.totalVolume;
ca.count += c.count;
}
const clusterArcs = [...clArcMap.values()].sort((a, b) => b.totalVolume - a.totalVolume);
// --- 4. Couleur de balance par cluster ---
const maxAbsNet = Math.max(...clusters.map(cl => Math.abs(cl.received - cl.emitted)), 1);
const clusterColors = clusters.map(cl => {
const net = cl.received - cl.emitted;
const t = net / maxAbsNet;
if (Math.abs(t) < NEUTRAL_THRESHOLD) return COLOR_NEUTRAL;
return t < 0
? lerpColor(COLOR_NEUTRAL, COLOR_NEG, -t)
: lerpColor(COLOR_NEUTRAL, COLOR_POS, t);
});
// Cluster de la ville focus
const focusClusterIdx = focusCity !== null ? (cityClusterIdx.get(focusCity) ?? -1) : -1;
// --- 5. Éléments SVG des arcs ---
const maxVol = Math.max(...clusterArcs.map(a => a.totalVolume), 1);
const arcElems = clusterArcs.map((ca, idx) => {
const p1 = { x: clusters[ca.fromIdx].cx, y: clusters[ca.fromIdx].cy };
const p2 = { x: clusters[ca.toIdx].cx, y: clusters[ca.toIdx].cy };
// Point de contrôle bezier quadratique : décalage perpendiculaire au milieu
const mx = (p1.x + p2.x) / 2;
const my = (p1.y + p2.y) / 2;
const dx = p2.x - p1.x;
@@ -98,13 +211,12 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
const cx = mx - dy * CURVE;
const cy = my + dx * CURVE;
// Flèche de direction au milieu (t = 0.5) du bezier
const t = 0.5;
const ax = (1-t)*(1-t)*p1.x + 2*(1-t)*t*cx + t*t*p2.x;
const ay = (1-t)*(1-t)*p1.y + 2*(1-t)*t*cy + t*t*p2.y;
const tln = Math.sqrt(dx*dx + dy*dy) || 1;
const nx = dx / tln; const ny = dy / tln; // tangente normalisée
const px = -ny; const py = nx; // perpendiculaire
const nx = dx / tln; const ny = dy / tln;
const px = -ny; const py = nx;
const AR = 5;
const arrowPts = [
`${ax + nx*AR},${ay + ny*AR}`,
@@ -112,40 +224,58 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
`${ax - nx*AR*0.6 - px*AR*0.5},${ay - ny*AR*0.6 - py*AR*0.5}`,
].join(' ');
const ratio = c.totalVolume / maxVol;
const strokeW = Math.max(1, 1.5 + Math.log1p(c.totalVolume) * 0.8);
const opacity = 0.35 + 0.55 * ratio;
const ratio = ca.totalVolume / maxVol;
const strokeW = Math.max(1, 1.5 + Math.log1p(ca.totalVolume) * 0.8);
const opacity = 0.35 + 0.55 * ratio;
// Couleur selon focusCity
const isFocusFrom = focusCity && c.fromCity === focusCity;
const isFocusTo = focusCity && c.toCity === focusCity;
const stroke = !focusCity ? `url(#grad${idx})`
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
const arrowFill = !focusCity ? '#e65100'
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
const isFocusFrom = focusClusterIdx !== -1 && ca.fromIdx === focusClusterIdx;
const isFocusTo = focusClusterIdx !== -1 && ca.toIdx === focusClusterIdx;
const stroke = focusClusterIdx === -1 ? `url(#grad${idx})`
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
const arrowFill = focusClusterIdx === -1 ? '#e65100'
: isFocusFrom ? '#ff8f00'
: isFocusTo ? '#00acc1'
: '#2e2f3a';
return {
idx, c, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
idx, ca, p1, p2, cx, cy, arrowPts, strokeW, opacity, stroke, arrowFill,
path: `M ${p1.x},${p1.y} Q ${cx},${cy} ${p2.x},${p2.y}`,
};
});
// ---- Nœuds ----
const nodeElems = [...cityNodes.entries()].map(([name, data]) => {
const p = proj(data.lat, data.lng);
const vol = data.emitted + data.received;
const r = Math.max(3, Math.min(14, 3 + 9 * (vol / maxNodeVol)));
return { name, p, r, isSelected: focusCity === name };
// --- 6. Éléments SVG des nœuds de clusters ---
const maxClVol = Math.max(...clusters.map(cl => cl.totalVol), 1);
const nodeElems = clusters.map((cl, idx) => {
const r = Math.max(4, Math.min(18, 4 + 11 * (cl.totalVol / maxClVol)));
const fillColor = clusterColors[idx];
const isSelected = focusClusterIdx === idx;
const cityCount = cl.cities.size;
// Nom affiché : ville principale (la première dans l'itération = la plus volumineuse)
const label = cityCount > 1 ? `+${cityCount}` : [...cl.cities][0];
return { idx, cl, r, fillColor, isSelected, cityCount, label };
});
return { arcElems, nodeElems };
// tick en dep pour re-projeter sur pan/zoom
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [corridors, cityNodes, focusCity, tick, mapReady]);
}, [corridors, cityNodes, focusCity, tick, mapReady, clustered]);
const [popupIdx, setPopupIdx] = useState<number | null>(null);
// Ferme le popup sur déplacement/zoom
useEffect(() => { setPopupIdx(null); }, [tick]);
// Handler de clic : ouvre/ferme le popup + focus
const handleNodeClick = (nodeIdx: number) => {
if (!svgElements) return;
const node = svgElements.nodeElems[nodeIdx];
const firstCity = [...node.cl.cities][0];
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
onCityClick(isCurrentFocus ? null : firstCity);
setPopupIdx(popupIdx === nodeIdx ? null : nodeIdx);
};
return (
<div className="w-full h-full relative" style={{ minHeight: 0 }}>
@@ -174,7 +304,7 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
{/* Arcs bezier */}
{svgElements.arcElems.map(a => (
<g key={`${a.c.fromCity}-${a.c.toCity}`} opacity={a.opacity}>
<g key={`${a.ca.fromIdx}-${a.ca.toIdx}`} opacity={a.opacity}>
<path
d={a.path}
fill="none"
@@ -186,24 +316,91 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
</g>
))}
{/* Nœuds de villes (pointer-events activés uniquement ici) */}
{/* Nœuds de clusters (pointer-events activés uniquement ici) */}
<g style={{ pointerEvents: 'all' }}>
{svgElements.nodeElems.map(node => (
<circle
key={node.name}
cx={node.p.x}
cy={node.p.y}
r={node.r}
fill={node.isSelected ? '#ffffff' : '#d4a843'}
stroke={node.isSelected ? '#d4a843' : '#0a0b0f'}
strokeWidth={1.5}
style={{ cursor: 'pointer' }}
onClick={() => onCityClick(focusCity === node.name ? null : node.name)}
/>
<g key={node.idx} onClick={() => handleNodeClick(node.idx)} style={{ cursor: 'pointer' }}>
<circle
cx={node.cl.cx}
cy={node.cl.cy}
r={node.r}
fill={node.isSelected ? '#ffffff' : node.fillColor}
stroke={node.isSelected ? node.fillColor : '#0a0b0f'}
strokeWidth={1.5}
/>
{node.cityCount > 1 && (
<text
x={node.cl.cx}
y={node.cl.cy + 3.5}
textAnchor="middle"
fontSize={node.r > 9 ? 9 : 7}
fontWeight="bold"
fill={node.isSelected ? node.fillColor : '#0a0b0f'}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{node.label}
</text>
)}
</g>
))}
</g>
</svg>
)}
{/* Bouton cluster / villes */}
<button
onClick={() => setClustered(c => !c)}
className={`absolute bottom-44 sm:bottom-24 left-4 z-[1002] px-3 py-1.5 rounded-lg text-xs font-medium border transition-all duration-200 ${
clustered
? 'bg-[#d4a843] text-[#0a0b0f] border-[#d4a843] shadow-[0_0_10px_rgba(212,168,67,0.35)]'
: 'bg-[#0a0b0f]/90 text-[#6b7280] border-[#2e2f3a] hover:text-[#d4a843] hover:border-[#d4a843]'
}`}
>
{clustered ? '⬡ Clusters' : '· Villes'}
</button>
{/* Popup cluster */}
{mapReady && svgElements && popupIdx !== null && (() => {
const node = svgElements.nodeElems[popupIdx];
const cities = [...node.cl.cities].map(name => {
const d = cityNodes.get(name);
const net = d ? d.received - d.emitted : 0;
return { name, net };
}).sort((a, b) => Math.abs(b.net) - Math.abs(a.net));
// Position : décale à droite du cercle, recadre si hors écran
const raw = node.cl.cx + node.r + 8;
const containerW = containerRef.current?.clientWidth ?? 0;
const popupW = 200;
const left = raw + popupW > containerW ? node.cl.cx - node.r - 8 - popupW : raw;
return (
<div
className="absolute z-[600] bg-[#0a0b0f]/95 border border-[#2e2f3a] rounded-xl p-3 shadow-xl"
style={{ left, top: Math.max(4, node.cl.cy - 80), width: popupW, pointerEvents: 'auto' }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-[#4b5563] text-[10px] uppercase tracking-widest">
{node.cityCount > 1 ? `${node.cityCount} villes` : 'Ville'}
</span>
<button
onClick={() => setPopupIdx(null)}
className="text-[#4b5563] hover:text-white text-xs leading-none ml-2"
></button>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{cities.map(({ name, net }) => (
<div key={name} className="flex items-center justify-between gap-1">
<span className="text-white text-xs truncate">{name}</span>
<span className={`text-xs font-mono shrink-0 ${net >= 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}>
{net >= 0 ? '+' : ''}{Math.round(net).toLocaleString('fr-FR')} Ğ1
</span>
</div>
))}
</div>
</div>
);
})()}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
interface InfoPanelProps {
onClose: () => void;
}
export function InfoPanel({ onClose }: InfoPanelProps) {
return (
<>
{/* Overlay */}
<div
className="fixed inset-0 z-[2000] bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modale */}
<div className="fixed z-[2001] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[min(520px,calc(100vw-2rem))] max-h-[80vh] overflow-y-auto rounded-2xl bg-[#0f1016] border border-[#2e2f3a] shadow-2xl">
{/* En-tête */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[#2e2f3a] sticky top-0 bg-[#0f1016] z-10">
<div>
<h2 className="text-[#d4a843] font-semibold text-base">Ğ1Flux</h2>
<p className="text-[#6b7280] text-xs mt-0.5">Explorateur de transactions Ğ1v2</p>
</div>
<button
onClick={onClose}
className="text-[#4b5563] hover:text-white text-xl leading-none p-1"
aria-label="Fermer"
>
</button>
</div>
{/* Contenu */}
<div className="px-5 py-4 flex flex-col gap-5 text-sm">
<Section title="Vues cartographiques">
<Feature icon="🌡" name="Heatmap">
Densité des transactions géolocalisées. Les zones chaudes concentrent le plus d'activité.
Basculer avec le bouton <Kbd>Heatmap / Flux</Kbd>.
</Feature>
<Feature icon="⟿" name="Flux">
Arcs entre villes représentant les flux de Ğ1. L'épaisseur indique le volume,
la couleur la direction dominante.
</Feature>
</Section>
<Section title="Clusters & villes (vue Flux)">
<Feature icon="⬡" name="Mode Clusters">
Les villes géographiquement proches sont regroupées en un nœud unique.
Le chiffre affiché indique le nombre de villes dans le groupe.
</Feature>
<Feature icon="·" name="Mode Villes">
Chaque ville est affichée individuellement, sans regroupement.
Basculer avec le bouton <Kbd> Clusters / · Villes</Kbd> (bas gauche de la carte).
</Feature>
<Feature icon="●" name="Couleur des nœuds">
<span className="text-green-400">Vert</span> = receveur net (reçoit plus que ce qu'il émet) ·{' '}
<span className="text-[#d4a843]">Or</span> = équilibré (dégradé or → vert selon l'excédent reçu) ·{' '}
<span className="text-[#e53935]">Rouge</span> = émetteur net (dégradé or rouge selon l'excédent émis).
</Feature>
<Feature icon="↗" name="Clic sur un nœud">
Affiche la liste des villes du cluster avec leur balance individuelle,
triée par valeur absolue.
</Feature>
</Section>
<Section title="Période">
<Feature icon="📅" name="Préréglages">
<Kbd>24h</Kbd> <Kbd>7 jours</Kbd> <Kbd>30 jours</Kbd> — fenêtre glissante jusqu'à maintenant.
</Feature>
<Feature icon="✎" name="Personnaliser">
Saisir une durée de 1 à 365 jours.
</Feature>
</Section>
<Section title="Animation">
<Feature icon="▶" name="Animer">
Rejoue les transactions frame par frame sur la période sélectionnée
(une frame = une journée).
</Feature>
<Feature icon="⏩" name="Contrôles">
Lecture / pause · Navigation frame par frame (<Kbd></Kbd> <Kbd></Kbd>) ·
Vitesse <Kbd>×1</Kbd> <Kbd>×2</Kbd> <Kbd>×4</Kbd>.
</Feature>
</Section>
<Section title="Statistiques">
<Feature icon="📊" name="Panneau latéral">
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.
</Feature>
<Feature icon="☰" name="Mobile">
Le panneau est accessible via le bouton <Kbd></Kbd> en haut à gauche.
</Feature>
<Feature icon="%" name="% Tx géoloc.">
Pourcentage des transactions ayant une géolocalisation connue sur la période / frame courante.
</Feature>
</Section>
<Section title="Source de données">
<Feature icon="●" name="Live Ğ1v2">
Données temps réel de la blockchain Ğ1v2, actualisées toutes les 30 secondes.
</Feature>
</Section>
</div>
</div>
</>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="text-[#d4a843] text-xs font-semibold uppercase tracking-wider mb-2">{title}</h3>
<div className="flex flex-col gap-2">{children}</div>
</div>
);
}
function Feature({ icon, name, children }: { icon: string; name: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<span className="text-[#4b5563] w-5 shrink-0 text-center leading-5 mt-0.5">{icon}</span>
<div>
<span className="text-white font-medium">{name}</span>
<span className="text-[#6b7280]"> </span>
<span className="text-[#9ca3af]">{children}</span>
</div>
</div>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<span className="inline-block bg-[#1a1b23] border border-[#2e2f3a] rounded px-1 py-0.5 text-[11px] text-[#d4a843] font-mono leading-none">
{children}
</span>
);
}
+11 -4
View File
@@ -7,6 +7,7 @@ interface PeriodSelectorProps {
onAnimate: () => void;
viewMode: 'heatmap' | 'flow';
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
geoPercent?: number | null;
}
const PERIODS = [
@@ -17,7 +18,7 @@ const PERIODS = [
const PRESET_DAYS = new Set([1, 7, 30]);
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange }: PeriodSelectorProps) {
export function PeriodSelector({ value, onChange, animationActive, onAnimate, viewMode, onViewModeChange, geoPercent }: PeriodSelectorProps) {
const [customOpen, setCustomOpen] = useState(false);
const [inputVal, setInputVal] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
@@ -41,13 +42,13 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
const isCustomActive = !PRESET_DAYS.has(value);
return (
<div className="flex gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center">
<div className="flex flex-wrap gap-1 bg-[#0f1016] border border-[#2e2f3a] rounded-lg p-1 items-center max-w-[calc(100vw-2rem)]">
{PERIODS.map(({ label, days }) => (
<button
key={days}
onClick={() => { onChange(days); setCustomOpen(false); }}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${value === days && !customOpen
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
@@ -84,7 +85,7 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
<button
onClick={openCustom}
className={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
px-3 py-2.5 sm:py-1.5 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer
${isCustomActive
? 'bg-[#d4a843] text-[#0a0b0f] shadow-[0_0_12px_rgba(212,168,67,0.4)]'
: 'text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23]'
@@ -124,6 +125,12 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
>
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
</button>
{geoPercent != null && (
<span className="text-[10px] font-mono text-white px-1 shrink-0">
{geoPercent}% Tx géoloc.
</span>
)}
</div>
);
}
+20 -3
View File
@@ -7,11 +7,13 @@ interface StatsPanelProps {
loading: boolean;
periodDays: number;
source: 'live' | 'mock';
className?: string;
currentUD: number;
animationLabel?: string;
viewMode?: 'heatmap' | 'flow';
flowStats?: FlowStats | null;
focusCity?: string | null;
onClose?: () => void;
}
const MEDALS = ['🥇', '🥈', '🥉'];
@@ -58,7 +60,7 @@ function CityRow({ city, volume, count, countryCode, accent }: {
);
}
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity }: StatsPanelProps) {
export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose, className }: StatsPanelProps) {
const periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
const prevStats = useRef<PeriodStats | null>(null);
@@ -82,7 +84,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
if (stats && !loading) prevStats.current = stats;
return (
<aside className="w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto">
<aside className={`flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto ${className ?? 'w-72 shrink-0'}`}>
{/* Header */}
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-[#d4a843] flex items-center justify-center text-[#0a0b0f] font-bold text-sm shadow-[0_0_16px_rgba(212,168,67,0.5)]">
@@ -95,6 +97,15 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
</h1>
<p className="text-[#4b5563] text-xs">Monnaie libre · Flux géo</p>
</div>
{onClose && (
<button
onClick={onClose}
className="ml-auto text-[#4b5563] hover:text-white transition-colors p-1 text-lg leading-none"
aria-label="Fermer"
>
</button>
)}
</div>
{/* Description */}
@@ -247,7 +258,13 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
{/* Balance nette */}
{flowStats.netBalance.length > 0 && (
<div className="bg-[#0f1016] border border-[#2e2f3a] rounded-xl p-3 space-y-1.5">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
<div className="flex items-center justify-between">
<p className="text-[#4b5563] text-xs uppercase tracking-widest">Balance nette</p>
<p className="text-[10px] text-[#4b5563] flex items-center gap-1.5">
<span style={{ color: '#ff6d00' }}></span>émetteur
<span style={{ color: '#00c853' }}></span>récepteur
</p>
</div>
{flowStats.netBalance.map((c) => (
<div key={c.city} className="flex items-center justify-between">
<span className="text-white text-xs truncate">{c.city}</span>
+12
View File
@@ -0,0 +1,12 @@
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [query]);
return matches;
}
+10 -5
View File
@@ -12,7 +12,7 @@
* Pour activer : définir VITE_USE_LIVE_API=true dans .env.local
*/
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD } from './adapters/SubsquidAdapter';
import { fetchTransfers, buildIdentityKeyMap, fetchCurrentUD, ss58ToDuniterKey } from './adapters/SubsquidAdapter';
import { resolveGeoByKeys, cleanCityName } from './adapters/CesiumAdapter';
import {
getTransactionsForPeriod,
@@ -69,9 +69,14 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
}
// Clés Duniter uniques des émetteurs ET destinataires (un seul appel Cesium+)
// Pour les membres WoT : via keyMap (genesis key = _id Cesium+)
// Pour les non-membres : conversion directe SS58 → Duniter key
const resolveKey = (ss58: string): string =>
keyMap.get(ss58) ?? ss58ToDuniterKey(ss58);
const allDuniterKeys = [...new Set([
...rawTransfers.map((t) => keyMap.get(t.fromId)),
...rawTransfers.map((t) => keyMap.get(t.toId)),
...rawTransfers.map((t) => t.fromId ? resolveKey(t.fromId) : undefined),
...rawTransfers.map((t) => t.toId ? resolveKey(t.toId) : undefined),
].filter(Boolean) as string[])];
// Résolution géo par clé Duniter (_id Cesium+)
@@ -89,7 +94,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
const arcs: TransactionArc[] = [];
for (const t of rawTransfers) {
const fromDuniterKey = keyMap.get(t.fromId);
const fromDuniterKey = t.fromId ? resolveKey(t.fromId) : undefined;
if (!fromDuniterKey) continue;
const fromGeo = geoMap.get(fromDuniterKey);
if (!fromGeo) continue;
@@ -110,7 +115,7 @@ async function fetchLiveTransactions(periodDays: number): Promise<{
});
// Arc : les deux extrémités géolocalisées + villes différentes
const toDuniterKey = keyMap.get(t.toId);
const toDuniterKey = t.toId ? resolveKey(t.toId) : undefined;
if (!toDuniterKey) continue;
const toGeo = geoMap.get(toDuniterKey);
if (!toGeo) continue;