diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2c1c077 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +docs-plan +docs-bugs +docs-syoul +*.md +.env* diff --git a/.woodpecker.yml b/.woodpecker.yml index 44d7074..d0d8bdc 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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: diff --git a/package.json b/package.json index c109fcd..7ac936c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "g1flux", "private": true, - "version": "1.2.0", + "version": "1.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index a797b9e..0f9d2a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ 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); @@ -27,6 +28,7 @@ export default function App() { const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap'); const [focusCity, setFocusCity] = useState(null); const [panelOpen, setPanelOpen] = useState(false); + const [infoOpen, setInfoOpen] = useState(false); const isMobile = useMediaQuery('(max-width: 639px)'); const animation = useAnimation(transactions, arcs, periodDays, allTimestamps); @@ -138,6 +140,10 @@ 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} + onInfo={() => setInfoOpen(true)} /> @@ -193,6 +199,9 @@ export default function App() { )} + {/* Info panel */} + {infoOpen && setInfoOpen(false)} />} + {/* Bottom drawer — mobile uniquement */} {isMobile && ( <> @@ -210,7 +219,7 @@ export default function App() {
- setPanelOpen(false)} /> + setPanelOpen(false)} className="w-full flex-1 min-h-0" />
)} diff --git a/src/components/FlowMap.tsx b/src/components/FlowMap.tsx index ea76337..68c263f 100644 --- a/src/components/FlowMap.tsx +++ b/src/components/FlowMap.tsx @@ -39,8 +39,9 @@ interface FlowMapProps { export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) { const containerRef = useRef(null); const mapRef = useRef(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(() => { @@ -114,33 +115,35 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) { 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); - // --- 2. Clustering glouton par distance pixel --- + // --- 2. Clustering glouton par distance pixel (ou 1 ville = 1 cluster) --- interface Cluster { - cx: number; cy: number; // centroïde pondéré (pixels) - lat: number; lng: number; // centroïde géo (pour debug éventuel) + cx: number; cy: number; + lat: number; lng: number; totalVol: number; emitted: number; received: number; cities: Set; } const clusters: Cluster[] = []; - const cityClusterIdx = new Map(); // nom ville → index cluster + const cityClusterIdx = new Map(); for (const city of cityList) { let bestIdx = -1; - 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 (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) { - // Nouvelle graine clusters.push({ cx: city.x, cy: city.y, lat: city.lat, lng: city.lng, @@ -150,7 +153,6 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) { }); cityClusterIdx.set(city.name, clusters.length - 1); } else { - // Fusionner dans le cluster existant (centroïde pondéré) const cl = clusters[bestIdx]; const newVol = cl.totalVol + city.vol; cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol; @@ -258,15 +260,21 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) { 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]); - // Handler de clic : on transmet la première ville du cluster cliqué + const [popupIdx, setPopupIdx] = useState(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 ( @@ -338,6 +346,61 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) { )} + + {/* Bouton cluster / villes */} + + + {/* 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 ( +
+
+ + {node.cityCount > 1 ? `${node.cityCount} villes` : 'Ville'} + + +
+
+ {cities.map(({ name, net }) => ( +
+ {name} + = 0 ? 'text-[#00acc1]' : 'text-[#ff8f00]'}`}> + {net >= 0 ? '+' : ''}{Math.round(net).toLocaleString('fr-FR')} Ğ1 + +
+ ))} +
+
+ ); + })()} ); } diff --git a/src/components/InfoPanel.tsx b/src/components/InfoPanel.tsx new file mode 100644 index 0000000..69374a3 --- /dev/null +++ b/src/components/InfoPanel.tsx @@ -0,0 +1,142 @@ +interface InfoPanelProps { + onClose: () => void; +} + +export function InfoPanel({ onClose }: InfoPanelProps) { + return ( + <> + {/* Overlay */} +
+ + {/* Modale */} +
+ + {/* En-tête */} +
+
+

Ğ1Flux

+

Explorateur de transactions Ğ1v2

+
+ +
+ + {/* Contenu */} +
+ +
+ + Densité des transactions géolocalisées. Les zones chaudes concentrent le plus d'activité. + Basculer avec le bouton Heatmap / Flux. + + + Arcs entre villes représentant les flux de Ğ1. L'épaisseur indique le volume, + la couleur la direction dominante. + +
+ +
+ + Les villes géographiquement proches sont regroupées en un nœud unique. + Le chiffre affiché indique le nombre de villes dans le groupe. + + + Chaque ville est affichée individuellement, sans regroupement. + Basculer avec le bouton ⬡ Clusters / · Villes (bas gauche de la carte). + + + Vert = receveur net (reçoit plus que ce qu'il émet) ·{' '} + Or = équilibré ·{' '} + Orange = émetteur net. + + + Affiche la liste des villes du cluster avec leur balance individuelle, + triée par valeur absolue. + +
+ +
+ + 24h 7 jours 30 jours — fenêtre glissante jusqu'à maintenant. + + + Saisir une durée de 1 à 365 jours. + +
+ +
+ + Rejoue les transactions frame par frame sur la période sélectionnée + (une frame = une journée). + + + Lecture / pause · Navigation frame par frame (◀◀ ▶▶) · + Vitesse ×1 ×2 ×4. + +
+ +
+ + 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. + + + Le panneau est accessible via le bouton en haut à gauche. + + + Pourcentage des transactions ayant une géolocalisation connue sur la période / frame courante. + +
+ +
+ + Données temps réel de la blockchain Ğ1v2, actualisées toutes les 30 secondes. + + + Données simulées, utilisées si l'API live est indisponible. + +
+ +
+
+ + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function Feature({ icon, name, children }: { icon: string; name: string; children: React.ReactNode }) { + return ( +
+ {icon} +
+ {name} + + {children} +
+
+ ); +} + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/components/PeriodSelector.tsx b/src/components/PeriodSelector.tsx index a5e2e1d..2ccb8ad 100644 --- a/src/components/PeriodSelector.tsx +++ b/src/components/PeriodSelector.tsx @@ -7,6 +7,8 @@ interface PeriodSelectorProps { onAnimate: () => void; viewMode: 'heatmap' | 'flow'; onViewModeChange: (mode: 'heatmap' | 'flow') => void; + geoPercent?: number | null; + onInfo: () => void; } const PERIODS = [ @@ -17,7 +19,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, onInfo }: PeriodSelectorProps) { const [customOpen, setCustomOpen] = useState(false); const [inputVal, setInputVal] = useState(''); const inputRef = useRef(null); @@ -124,6 +126,21 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi > {viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'} + {geoPercent != null && ( + + {geoPercent}% Tx géoloc. + + )} + +
+ +
); } diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index ce808fc..37d50fc 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -7,6 +7,7 @@ interface StatsPanelProps { loading: boolean; periodDays: number; source: 'live' | 'mock'; + className?: string; currentUD: number; animationLabel?: string; viewMode?: 'heatmap' | 'flow'; @@ -59,7 +60,7 @@ function CityRow({ city, volume, count, countryCode, accent }: { ); } -export function StatsPanel({ stats, loading, periodDays, source, currentUD, animationLabel, viewMode = 'heatmap', flowStats, focusCity, onClose }: 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(null); @@ -83,7 +84,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim if (stats && !loading) prevStats.current = stats; return ( -