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:
@ -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,43 +492,44 @@ 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>
|
||||
)}
|
||||
|
||||
<DeckGL
|
||||
views={new OrthographicView({ id: 'ortho', controller: true })}
|
||||
viewState={viewState}
|
||||
onViewStateChange={({ viewState: vs }) => setViewState(vs as typeof viewState)}
|
||||
layers={layers as any}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
controller={true}
|
||||
>
|
||||
{/* Légende overlay */}
|
||||
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'none' }}>
|
||||
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1">
|
||||
{[['#dc2626', 'CRITICAL'], ['#f97316', 'HIGH'], ['#eab308', 'MEDIUM'], ['#22c55e', 'LOW']].map(([c, l]) => (
|
||||
<div key={l} className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ background: c }} />
|
||||
<span className="text-white/80">{l}</span>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
/* Canvas WebGL — monté seulement quand il y a des données */
|
||||
<DeckGL
|
||||
views={new OrthographicView({ id: 'ortho', controller: true })}
|
||||
viewState={viewState}
|
||||
onViewStateChange={({ viewState: vs }) => setViewState(vs as typeof viewState)}
|
||||
layers={layers as any}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
controller={true}
|
||||
>
|
||||
{/* Légende overlay */}
|
||||
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'none' }}>
|
||||
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1">
|
||||
{[['#dc2626', 'CRITICAL'], ['#f97316', 'HIGH'], ['#eab308', 'MEDIUM'], ['#22c55e', 'LOW']].map(([c, l]) => (
|
||||
<div key={l} className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ background: c }} />
|
||||
<span className="text-white/80">{l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tooltip zoom hint */}
|
||||
<div style={{ position: 'absolute', bottom: 16, right: selected ? 320 : 16, pointerEvents: 'none' }}>
|
||||
<div className="text-xs text-white/40">Scroll pour zoomer · Drag pour déplacer · Click sur un cluster</div>
|
||||
</div>
|
||||
</DeckGL>
|
||||
{/* Tooltip zoom hint */}
|
||||
<div style={{ position: 'absolute', bottom: 16, right: selected ? 320 : 16, pointerEvents: 'none' }}>
|
||||
<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) ── */}
|
||||
|
||||
Reference in New Issue
Block a user