fix(clustering): animation visible, params persistés, bouton toujours actif

Bug 1 — Animation invisible (WebGL canvas ignore z-index):
- DeckGL n'est plus rendu pendant computing||loading
- Structure ternaire : animation | état vide | DeckGL (mutuellement exclusifs)
- Le canvas WebGL n'est monté que quand des données sont disponibles
- Animation garantie visible car aucun élément WebGL ne la couvre

Bug 2 — Bouton 'Recalculer' inactif pendant computing:
- disabled={loading} seulement (plus disabled pendant computing)
- L'utilisateur peut relancer pendant un calcul en cours
- Le texte du bouton indique l'état : 'Calcul en cours…' / 'Chargement…' / 'Recalculer'

Bug 3 — Paramètres perdus au rechargement:
- loadParams() lit les params depuis localStorage (clé: soc_clustering_params)
- useState initialisé depuis loadParams() au montage du composant
- useEffect sauvegarde k, hours, sensitivity dans localStorage à chaque changement
- Les réglages (k, sensibilité, fenêtre) survivent aux rechargements

Fix stale closure:
- sensitivity ajouté aux dépendances de useCallback fetchClusters
- Évite d'envoyer une ancienne valeur de sensitivity à l'API

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SOC Analyst
2026-03-19 11:32:52 +01:00
parent f83263771f
commit 136bc345d9

View File

@ -93,10 +93,21 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
// ─── Composant principal ──────────────────────────────────────────────────────
// Persistence des paramètres dans localStorage
const LS_KEY = 'soc_clustering_params';
function loadParams() {
try {
const s = localStorage.getItem(LS_KEY);
if (s) return JSON.parse(s) as { k: number; hours: number; sensitivity: number };
} catch { /* ignore */ }
return { k: 20, hours: 24, sensitivity: 1.0 };
}
export default function ClusteringView() {
const [k, setK] = useState(20);
const [hours, setHours] = useState(24);
const [sensitivity, setSensitivity] = useState(1.0);
const init = loadParams();
const [k, setK] = useState(init.k);
const [hours, setHours] = useState(init.hours);
const [sensitivity, setSensitivity] = useState(init.sensitivity);
const [data, setData] = useState<ClusterResult | null>(null);
const [loading, setLoading] = useState(false);
const [computing, setComputing] = useState(false);
@ -117,9 +128,15 @@ export default function ClusteringView() {
maxZoom: 6,
});
// ── Persistence des paramètres ──────────────────────────────────────────
useEffect(() => {
localStorage.setItem(LS_KEY, JSON.stringify({ k, hours, sensitivity }));
}, [k, hours, sensitivity]);
// ── Chargement / polling ─────────────────────────────────────────────────
const fetchClusters = useCallback(async (force = false) => {
if (pollRef.current) { clearTimeout(pollRef.current); pollRef.current = null; }
setLoading(true);
setError(null);
try {
@ -128,7 +145,7 @@ export default function ClusteringView() {
});
if (res.data.status === 'computing' || res.data.status === 'idle') {
setComputing(true);
// Polling
// Polling toutes les 3s
pollRef.current = setTimeout(() => fetchClusters(), 3000);
} else {
setComputing(false);
@ -139,10 +156,10 @@ export default function ClusteringView() {
const ys = res.data.nodes.map(n => toWorld(n.pca_y));
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const pad = 0.18; // 18% de marge de chaque côté
const pad = 0.18;
const fitW = (maxX - minX) * (1 + 2 * pad) || WORLD;
const fitH = (maxY - minY) * (1 + 2 * pad) || WORLD;
const canvasW = window.innerWidth - 288 - (selected ? 384 : 0); // panel + sidebar
const canvasW = window.innerWidth - 288 - (selected ? 384 : 0);
const canvasH = window.innerHeight - 60;
setViewState(v => ({
...v,
@ -160,7 +177,7 @@ export default function ClusteringView() {
} finally {
setLoading(false);
}
}, [k, hours]);
}, [k, hours, sensitivity]); // sensitivity inclus pour éviter la stale closure
useEffect(() => {
fetchClusters();
@ -387,9 +404,9 @@ export default function ClusteringView() {
Afficher les arêtes
</label>
<button onClick={() => fetchClusters(true)}
disabled={loading || computing}
disabled={loading}
className="w-full py-2 bg-accent-primary text-white rounded text-sm font-medium hover:opacity-90 disabled:opacity-50">
{computing ? '⏳ Calcul…' : loading ? '⏳ Chargement…' : '🔄 Recalculer'}
{computing ? '⏳ Calcul en cours…' : loading ? '⏳ Chargement…' : '🔄 Recalculer'}
</button>
</div>
@ -442,12 +459,12 @@ export default function ClusteringView() {
{/* ── Canvas WebGL (deck.gl) ── */}
<div className="flex-1 relative overflow-hidden">
{/* Animation de calcul — remplace le canvas pendant le traitement */}
{(computing || loading) && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-background">
{/* Animation de calcul — REMPLACE DeckGL (le canvas WebGL ignore z-index) */}
{(computing || loading) ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background">
{/* Noeuds pulsants animés */}
<div className="relative w-56 h-56 mb-2">
{/* Anneau tournant */}
{/* Anneaux tournants */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-2 border-accent-primary/20 border-t-accent-primary animate-spin" style={{ animationDuration: '1.4s' }} />
</div>
@ -457,9 +474,8 @@ export default function ClusteringView() {
{/* Noeuds orbitaux représentant les clusters */}
{([0,1,2,3,4,5,6,7] as const).map((i) => {
const angle = (i / 8) * 2 * Math.PI;
const r = 88;
const x = 50 + (r / 1.12) * Math.cos(angle);
const y = 50 + (r / 1.12) * Math.sin(angle);
const x = 50 + 39 * Math.cos(angle);
const y = 50 + 39 * Math.sin(angle);
const colors = ['#dc2626','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6'];
return (
<div key={i} className="absolute w-3 h-3 rounded-full animate-ping"
@ -476,19 +492,19 @@ export default function ClusteringView() {
</div>
</div>
<p className="text-white font-semibold text-lg tracking-wide">Clustering en cours</p>
<p className="text-text-secondary text-sm mt-1">K-means++ · 31 features · toutes les IPs</p>
<p className="text-text-secondary text-sm mt-1">
K-means++ · 31 features · {Math.round(k * sensitivity)} clusters · toutes les IPs
</p>
<p className="text-text-disabled text-xs mt-2 animate-pulse">Mise à jour automatique toutes les 3 secondes</p>
</div>
)}
{/* Message état vide */}
{!data && !loading && !computing && (
) : !data ? (
/* État vide initial */
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-text-secondary">
<span className="text-4xl">🔬</span>
<span>Cliquez sur <strong className="text-white">Recalculer</strong> pour démarrer</span>
</div>
)}
) : (
/* Canvas WebGL — monté seulement quand il y a des données */
<DeckGL
views={new OrthographicView({ id: 'ortho', controller: true })}
viewState={viewState}
@ -513,6 +529,7 @@ export default function ClusteringView() {
<div className="text-xs text-white/40">Scroll pour zoomer · Drag pour déplacer · Click sur un cluster</div>
</div>
</DeckGL>
)}
</div>
{/* ── Sidebar droite (sélection) ── */}