- 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>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
docs-plan
|
||||||
|
docs-bugs
|
||||||
|
docs-syoul
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
+6
-4
@@ -45,20 +45,22 @@ steps:
|
|||||||
|
|
||||||
# Etape 4a : Generation SBOM (Syft) depuis l'image locale
|
# Etape 4a : Generation SBOM (Syft) depuis l'image locale
|
||||||
# NOTE: volumes + pas de from_secret : compatible
|
# 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
|
- name: sbom-generate
|
||||||
image: alpine:3.20
|
image: alpine:3.20
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
commands:
|
commands:
|
||||||
- apk add --no-cache curl
|
- apk add --no-cache curl tar
|
||||||
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
- 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
|
- 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"
|
- echo "SBOM genere"
|
||||||
|
|
||||||
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
|
# Etape 4b : Scan CVE (Trivy) depuis le SBOM
|
||||||
- name: sbom-scan
|
- name: sbom-scan
|
||||||
image: aquasec/trivy:latest
|
image: aquasec/trivy:0.69.3
|
||||||
volumes:
|
volumes:
|
||||||
- /home/syoul/trivy-cache:/root/.cache/trivy
|
- /home/syoul/trivy-cache:/root/.cache/trivy
|
||||||
commands:
|
commands:
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "g1flux",
|
"name": "g1flux",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+10
-1
@@ -12,6 +12,7 @@ import { computeStats } from './data/mockData';
|
|||||||
import { computeFlowStats } from './data/arcData';
|
import { computeFlowStats } from './data/arcData';
|
||||||
import { useAnimation } from './hooks/useAnimation';
|
import { useAnimation } from './hooks/useAnimation';
|
||||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||||
|
import { InfoPanel } from './components/InfoPanel';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [periodDays, setPeriodDays] = useState(7);
|
const [periodDays, setPeriodDays] = useState(7);
|
||||||
@@ -27,6 +28,7 @@ export default function App() {
|
|||||||
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
const [viewMode, setViewMode] = useState<'heatmap' | 'flow'>('heatmap');
|
||||||
const [focusCity, setFocusCity] = useState<string | null>(null);
|
const [focusCity, setFocusCity] = useState<string | null>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const isMobile = useMediaQuery('(max-width: 639px)');
|
const isMobile = useMediaQuery('(max-width: 639px)');
|
||||||
|
|
||||||
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
const animation = useAnimation(transactions, arcs, periodDays, allTimestamps);
|
||||||
@@ -138,6 +140,10 @@ export default function App() {
|
|||||||
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
onAnimate={() => animation.active ? animation.deactivate() : animation.activate()}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={handleViewModeChange}
|
onViewModeChange={handleViewModeChange}
|
||||||
|
geoPercent={visibleStats && visibleStats.transactionCount > 0
|
||||||
|
? Math.round((visibleStats.geoCount / visibleStats.transactionCount) * 100)
|
||||||
|
: null}
|
||||||
|
onInfo={() => setInfoOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,6 +199,9 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Info panel */}
|
||||||
|
{infoOpen && <InfoPanel onClose={() => setInfoOpen(false)} />}
|
||||||
|
|
||||||
{/* Bottom drawer — mobile uniquement */}
|
{/* Bottom drawer — mobile uniquement */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<>
|
<>
|
||||||
@@ -210,7 +219,7 @@ export default function App() {
|
|||||||
<div className="flex justify-center pt-2 pb-1 bg-[#0a0b0f] rounded-t-2xl border-t border-x border-[#2e2f3a] shrink-0">
|
<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 className="w-10 h-1 rounded-full bg-[#2e2f3a]" />
|
||||||
</div>
|
</div>
|
||||||
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} />
|
<StatsPanel {...statsPanelProps} onClose={() => setPanelOpen(false)} className="w-full flex-1 min-h-0" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+82
-19
@@ -39,8 +39,9 @@ interface FlowMapProps {
|
|||||||
export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const [tick, setTick] = useState(0); // incrémenté sur moveend/zoomend → re-render
|
const [tick, setTick] = useState(0);
|
||||||
|
const [clustered, setClustered] = useState(true);
|
||||||
|
|
||||||
// Initialisation Leaflet
|
// Initialisation Leaflet
|
||||||
useEffect(() => {
|
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 };
|
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);
|
}).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 {
|
interface Cluster {
|
||||||
cx: number; cy: number; // centroïde pondéré (pixels)
|
cx: number; cy: number;
|
||||||
lat: number; lng: number; // centroïde géo (pour debug éventuel)
|
lat: number; lng: number;
|
||||||
totalVol: number;
|
totalVol: number;
|
||||||
emitted: number; received: number;
|
emitted: number; received: number;
|
||||||
cities: Set<string>;
|
cities: Set<string>;
|
||||||
}
|
}
|
||||||
const clusters: Cluster[] = [];
|
const clusters: Cluster[] = [];
|
||||||
const cityClusterIdx = new Map<string, number>(); // nom ville → index cluster
|
const cityClusterIdx = new Map<string, number>();
|
||||||
|
|
||||||
for (const city of cityList) {
|
for (const city of cityList) {
|
||||||
let bestIdx = -1;
|
let bestIdx = -1;
|
||||||
let bestDist = Infinity;
|
|
||||||
for (let i = 0; i < clusters.length; i++) {
|
if (clustered) {
|
||||||
const cl = clusters[i];
|
let bestDist = Infinity;
|
||||||
const dx = city.x - cl.cx;
|
for (let i = 0; i < clusters.length; i++) {
|
||||||
const dy = city.y - cl.cy;
|
const cl = clusters[i];
|
||||||
const d = Math.sqrt(dx * dx + dy * dy);
|
const dx = city.x - cl.cx;
|
||||||
if (d < CLUSTER_RADIUS && d < bestDist) {
|
const dy = city.y - cl.cy;
|
||||||
bestDist = d;
|
const d = Math.sqrt(dx * dx + dy * dy);
|
||||||
bestIdx = i;
|
if (d < CLUSTER_RADIUS && d < bestDist) {
|
||||||
|
bestDist = d;
|
||||||
|
bestIdx = i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestIdx === -1) {
|
if (bestIdx === -1) {
|
||||||
// Nouvelle graine
|
|
||||||
clusters.push({
|
clusters.push({
|
||||||
cx: city.x, cy: city.y,
|
cx: city.x, cy: city.y,
|
||||||
lat: city.lat, lng: city.lng,
|
lat: city.lat, lng: city.lng,
|
||||||
@@ -150,7 +153,6 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
});
|
});
|
||||||
cityClusterIdx.set(city.name, clusters.length - 1);
|
cityClusterIdx.set(city.name, clusters.length - 1);
|
||||||
} else {
|
} else {
|
||||||
// Fusionner dans le cluster existant (centroïde pondéré)
|
|
||||||
const cl = clusters[bestIdx];
|
const cl = clusters[bestIdx];
|
||||||
const newVol = cl.totalVol + city.vol;
|
const newVol = cl.totalVol + city.vol;
|
||||||
cl.cx = (cl.cx * cl.totalVol + city.x * city.vol) / newVol;
|
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 };
|
return { arcElems, nodeElems };
|
||||||
// tick en dep pour re-projeter sur pan/zoom
|
// tick en dep pour re-projeter sur pan/zoom
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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<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) => {
|
const handleNodeClick = (nodeIdx: number) => {
|
||||||
if (!svgElements) return;
|
if (!svgElements) return;
|
||||||
const node = svgElements.nodeElems[nodeIdx];
|
const node = svgElements.nodeElems[nodeIdx];
|
||||||
const firstCity = [...node.cl.cities][0];
|
const firstCity = [...node.cl.cities][0];
|
||||||
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
|
const isCurrentFocus = node.cl.cities.has(focusCity ?? '');
|
||||||
onCityClick(isCurrentFocus ? null : firstCity);
|
onCityClick(isCurrentFocus ? null : firstCity);
|
||||||
|
setPopupIdx(popupIdx === nodeIdx ? null : nodeIdx);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -338,6 +346,61 @@ export function FlowMap({ arcs, focusCity, onCityClick }: FlowMapProps) {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bouton cluster / villes */}
|
||||||
|
<button
|
||||||
|
onClick={() => setClustered(c => !c)}
|
||||||
|
className={`absolute bottom-32 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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é ·{' '}
|
||||||
|
<span className="text-orange-400">Orange</span> = émetteur net.
|
||||||
|
</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>
|
||||||
|
<Feature icon="○" name="Mock">
|
||||||
|
Données simulées, utilisées si l'API live est indisponible.
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ interface PeriodSelectorProps {
|
|||||||
onAnimate: () => void;
|
onAnimate: () => void;
|
||||||
viewMode: 'heatmap' | 'flow';
|
viewMode: 'heatmap' | 'flow';
|
||||||
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
onViewModeChange: (mode: 'heatmap' | 'flow') => void;
|
||||||
|
geoPercent?: number | null;
|
||||||
|
onInfo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
@@ -17,7 +19,7 @@ const PERIODS = [
|
|||||||
|
|
||||||
const PRESET_DAYS = new Set([1, 7, 30]);
|
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 [customOpen, setCustomOpen] = useState(false);
|
||||||
const [inputVal, setInputVal] = useState('');
|
const [inputVal, setInputVal] = useState('');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -124,6 +126,21 @@ export function PeriodSelector({ value, onChange, animationActive, onAnimate, vi
|
|||||||
>
|
>
|
||||||
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
|
{viewMode === 'flow' ? '⊙ Heatmap' : '◉ Flux'}
|
||||||
</button>
|
</button>
|
||||||
|
{geoPercent != null && (
|
||||||
|
<span className="text-[10px] font-mono text-white px-1 shrink-0">
|
||||||
|
{geoPercent}% Tx géoloc.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="w-px mx-1 bg-[#2e2f3a] self-stretch" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onInfo}
|
||||||
|
className="px-2 py-1.5 rounded-md text-sm text-[#6b7280] hover:text-[#d4a843] hover:bg-[#1a1b23] transition-all duration-200 cursor-pointer leading-none"
|
||||||
|
aria-label="Aide"
|
||||||
|
>
|
||||||
|
ℹ
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface StatsPanelProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
periodDays: number;
|
periodDays: number;
|
||||||
source: 'live' | 'mock';
|
source: 'live' | 'mock';
|
||||||
|
className?: string;
|
||||||
currentUD: number;
|
currentUD: number;
|
||||||
animationLabel?: string;
|
animationLabel?: string;
|
||||||
viewMode?: 'heatmap' | 'flow';
|
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 periodLabel = periodDays === 1 ? '24 dernières heures' : `${periodDays} derniers jours`;
|
||||||
const prevStats = useRef<PeriodStats | null>(null);
|
const prevStats = useRef<PeriodStats | null>(null);
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ export function StatsPanel({ stats, loading, periodDays, source, currentUD, anim
|
|||||||
if (stats && !loading) prevStats.current = stats;
|
if (stats && !loading) prevStats.current = stats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-full lg:w-72 shrink-0 flex flex-col gap-4 bg-[#0a0b0f]/95 backdrop-blur-sm border-r border-[#1e1f2a] p-5 overflow-y-auto h-full">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2.5">
|
<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)]">
|
<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)]">
|
||||||
|
|||||||
Reference in New Issue
Block a user