feat: slider sensibilité + z-score standardization pour clustering plus précis

Sensibilité (0.5x–3.0x) :
- Multiplie k : sensibilité=2x avec k=14 → 28 clusters effectifs
- Labels UI : Grossière / Normale / Fine / Très fine / Maximum
- Paramètres avancés (k, fenêtre) masqués dans un <details>
- Cache invalidé si sensibilité change

Z-score standardisation (Bishop 2006 PRML §9.1) :
- Normalise par variance de chaque feature avant K-means
- Features discriminantes (forte std) pèsent plus
- Résultat : risque 0→1.00 sur clusters bots vs 0→0.27 avant
- Bots détectés : 4 337 IPs vs 1 604 (2.7x plus)
- Nouveaux clusters : Bot agressif, Tunnel réseau, UA-CH Mismatch distincts

Fix TextLayer deck.gl :
- Translittération des accents (é→e, à→a, ç→c…) + strip emojis
- Évite les warnings 'Missing character' sur caractères non-ASCII

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SOC Analyst
2026-03-19 10:07:23 +01:00
parent 08054cb571
commit fc3392779b
3 changed files with 118 additions and 50 deletions

View File

@ -96,6 +96,7 @@ function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
export default function ClusteringView() {
const [k, setK] = useState(14);
const [hours, setHours] = useState(24);
const [sensitivity, setSensitivity] = useState(1.0);
const [data, setData] = useState<ClusterResult | null>(null);
const [loading, setLoading] = useState(false);
const [computing, setComputing] = useState(false);
@ -123,7 +124,7 @@ export default function ClusteringView() {
setError(null);
try {
const res = await axios.get<ClusterResult>('/api/clustering/clusters', {
params: { k, hours, force },
params: { k, hours, sensitivity, force },
});
if (res.data.status === 'computing' || res.data.status === 'idle') {
setComputing(true);
@ -298,13 +299,17 @@ export default function ClusteringView() {
updateTriggers: { getFillColor: [selected?.id], getLineWidth: [selected?.id] },
}));
// 5. Labels (TextLayer) — strip emojis (non supportés par le bitmap font deck.gl)
const stripEmoji = (s: string) => s.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, '').trim();
const stripNonAscii = (s: string) =>
s.replace(/[\u{0080}-\u{FFFF}]/gu, c => {
// Translitérations basiques pour la lisibilité
const map: Record<string, string> = { é:'e',è:'e',ê:'e',ë:'e',à:'a',â:'a',ô:'o',ù:'u',û:'u',î:'i',ï:'i',ç:'c' };
return map[c] ?? '';
}).replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, '').trim();
layerList.push(new TextLayer({
id: 'labels',
data: nodes,
getPosition: (d: ClusterNode) => [toWorld(d.pca_x), toWorld(d.pca_y), 0],
getText: (d: ClusterNode) => stripEmoji(d.label),
getText: (d: ClusterNode) => stripNonAscii(d.label),
getSize: 12,
sizeUnits: 'pixels',
getColor: [255, 255, 255, 200],
@ -334,24 +339,48 @@ export default function ClusteringView() {
{/* Paramètres */}
<div className="bg-background-card rounded-lg p-3 space-y-3">
<label className="block text-xs text-text-secondary">
Clusters (k)
<input type="range" min={4} max={30} value={k}
onChange={e => setK(+e.target.value)}
className="w-full mt-1 accent-accent-primary" />
<span className="font-mono text-white">{k}</span>
</label>
<label className="block text-xs text-text-secondary">
Fenêtre
<select value={hours} onChange={e => setHours(+e.target.value)}
className="w-full mt-1 bg-background border border-gray-600 rounded px-2 py-1 text-sm">
<option value={6}>6h</option>
<option value={12}>12h</option>
<option value={24}>24h</option>
<option value={48}>48h</option>
<option value={168}>7j</option>
</select>
</label>
{/* Sensibilité */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-text-secondary">
<span>Sensibilité</span>
<span className="font-mono text-white">
{sensitivity === 0.5 ? 'Grossière' : sensitivity <= 1.0 ? 'Normale' : sensitivity <= 1.5 ? 'Fine' : sensitivity <= 2.0 ? 'Très fine' : 'Maximum'}
{' '}({Math.round(k * sensitivity)} clusters)
</span>
</div>
<input type="range" min={0.5} max={3.0} step={0.5} value={sensitivity}
onChange={e => setSensitivity(+e.target.value)}
className="w-full accent-accent-primary" />
<div className="flex justify-between text-xs text-text-disabled">
<span>Grossière</span><span>Maximum</span>
</div>
</div>
{/* k avancé */}
<details className="text-xs text-text-secondary">
<summary className="cursor-pointer hover:text-white">Paramètres avancés</summary>
<div className="mt-2 space-y-2">
<label className="block">
Clusters de base (k)
<input type="range" min={4} max={30} value={k}
onChange={e => setK(+e.target.value)}
className="w-full mt-1 accent-accent-primary" />
<span className="font-mono text-white">{k}</span>
</label>
<label className="block">
Fenêtre
<select value={hours} onChange={e => setHours(+e.target.value)}
className="w-full mt-1 bg-background border border-gray-600 rounded px-2 py-1">
<option value={6}>6h</option>
<option value={12}>12h</option>
<option value={24}>24h</option>
<option value={48}>48h</option>
<option value={168}>7j</option>
</select>
</label>
</div>
</details>
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer">
<input type="checkbox" checked={showEdges} onChange={e => setShowEdges(e.target.checked)}
className="accent-accent-primary" />