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