Compare commits

...

2 Commits

Author SHA1 Message Date
CyberMind
630cb81e03
Merge pull request #588 from CyberMind-FR/feature/587-eye-graph-domain-suffix-country-cloud-nu
Some checks are pending
License Headers / check (push) Waiting to run
Cartographie: domain-nugget cloud view (#587)
2026-06-14 17:21:02 +02:00
0f3f57e7bb feat(toolbox): domain-nugget cloud view, grouped by country (closes #587)
New '🏷️ Domaines' toggle: trackers folded to eTLD+1, packed as cloud-nugget
bubbles grouped by country (country→domain d3.pack), sized by hits,
tier-coloured, flag+name, click→domain summary. IPs hidden. Mirrors the
donut pack. secubox-toolbox 2.6.34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:20:40 +02:00
2 changed files with 93 additions and 0 deletions

View File

@ -1,3 +1,12 @@
secubox-toolbox (2.6.34-1~bookworm1) bookworm; urgency=medium
* Cartographie: domain-nugget cloud view (#587). New "🏷️ Domaines" toggle
(alongside 🍩 Donuts / 👁️ Œil) — trackers folded to eTLD+1 and packed as
cloud-nugget bubbles grouped by country (country→domain d3.pack), sized by
hits, tier-coloured, flag+name labels, click → domain summary. IPs hidden.
-- Gerald KERMA <devel@cybermind.fr> Sun, 14 Jun 2026 15:45:00 +0200
secubox-toolbox (2.6.33-1~bookworm1) bookworm; urgency=medium
* Shared broadcast pin in every banner (#578). An operator-set "📌 pin"

View File

@ -85,9 +85,21 @@
function draw(graph) {
if (!graph) return;
if (view === 'donuts') renderDonuts(graph);
else if (view === 'nuggets') renderNuggets(graph);
else render(graph);
}
// #587 — registrable domain (eTLD+1) for the domain-nugget view.
const _2L = ['co.uk', 'com.au', 'co.jp', 'co.nz', 'com.br', 'co.za', 'gouv.fr'];
function registrable(host) {
host = (host || '').split(':')[0].toLowerCase().replace(/^\.+|\.+$/g, '');
if (!host || /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':')) return null;
const p = host.split('.');
if (p.length <= 2) return host;
const last2 = p.slice(-2).join('.');
return (_2L.includes(last2) && p.length >= 3) ? p.slice(-3).join('.') : last2;
}
// Inject the view toggle (🍩 donuts ⇄ 👁️ œil) once, above the svg.
function ensureToggle() {
if (document.getElementById('view-toggle') || !svgEl) return;
@ -107,6 +119,7 @@
return b;
};
bar.appendChild(mk('donuts', '🍩 Donuts'));
bar.appendChild(mk('nuggets', '🏷️ Domaines'));
bar.appendChild(mk('eye', '👁️ Œil'));
svgEl.parentNode.insertBefore(bar, svgEl);
syncToggle();
@ -516,6 +529,77 @@
ndEl.hidden = false;
}
// #587 — domain-nugget cloud : trackers folded to eTLD+1, packed as
// cloud bubbles grouped by country (country→domain d3.pack). IPs hidden.
function _shortDom(d) { d = d || ''; return d.length > 16 ? d.slice(0, 15) + '…' : d; }
function focusDomain(d) {
if (!ndEl) return;
bind('nd_domain', (d.flag || '') + ' ' + d.domain);
bind('nd_country', d.flag || '—'); bind('nd_asn', '—');
bind('nd_cdn', d.tier || '—'); bind('nd_antibot', '—'); bind('nd_opgrade', '—');
bind('nd_sites', '~' + Math.round(d.value) + ' hits');
bind('nd_first_seen', '—'); bind('nd_last_seen', '—');
ndEl.hidden = false;
}
function renderNuggets(graph) {
clearGraph();
const { W, H } = svgSize();
svg.attr('viewBox', `0 0 ${W} ${H}`);
bind('total_trackers', graph.stats.total_trackers || 0);
bind('total_sites', graph.stats.total_sites || 0);
updateAntibotTile(graph.stats.antibot_sites || 0, graph.stats.antibot_vendors || []);
updateOpgradeTile(graph.stats.opgrade_sites || 0, graph.stats.opgrade_vendors || []);
const byDom = new Map();
for (const n of (graph.nodes || [])) {
const d = registrable(n.domain);
if (!d) continue; // skip IPs
let e = byDom.get(d);
if (!e) { e = { domain: d, hits: 0, flag: n.country_flag || '', cc: n.country_iso || '??', tier: n.tier || 'other' }; byDom.set(d, e); }
e.hits += (n.hits || 0);
if (!e.flag && n.country_flag) { e.flag = n.country_flag; e.cc = n.country_iso || '??'; }
}
const doms = [...byDom.values()];
if (!doms.length) return;
const byCC = new Map();
for (const d of doms) {
const k = d.cc || '??';
if (!byCC.has(k)) byCC.set(k, { cc: k, flag: d.flag, list: [] });
byCC.get(k).list.push(d);
}
const root = { name: 'root', children: [...byCC.values()].map(c => ({
name: c.cc, flag: c.flag, country: true,
children: c.list.map(d => ({ leaf: true, domain: d.domain, flag: d.flag, tier: d.tier, value: Math.max(d.hits, 1) })),
})) };
const h = d3.hierarchy(root).sum(d => d.value || 0)
.sort((a, b) => (b.value || 0) - (a.value || 0));
d3.pack().size([W, H]).padding(d => d.depth === 1 ? 12 : 3)(h);
const content = svg.append('g').attr('class', 'content');
content.append('g').selectAll('circle.cc')
.data(h.descendants().filter(d => d.depth === 1)).join('circle')
.attr('cx', d => d.x).attr('cy', d => d.y).attr('r', d => d.r)
.attr('fill', 'rgba(0,212,255,0.05)')
.attr('stroke', 'rgba(0,212,255,0.4)').attr('stroke-dasharray', '2,3');
content.append('g').selectAll('text.cc')
.data(h.descendants().filter(d => d.depth === 1)).join('text')
.attr('x', d => d.x).attr('y', d => d.y - d.r + 13).attr('text-anchor', 'middle')
.attr('fill', 'var(--cyber-cyan,#00d4ff)').attr('font-size', 12).attr('font-weight', 'bold')
.text(d => (d.data.flag || '🏴') + ' ' + (d.data.name || '?'));
const leaves = content.append('g').selectAll('g.nug')
.data(h.leaves()).join('g')
.attr('class', 'node').attr('transform', d => `translate(${d.x},${d.y})`)
.style('cursor', 'pointer').on('click', (ev, d) => focusDomain(d.data));
leaves.append('circle').attr('r', d => d.r)
.attr('fill', d => (TIER[d.data.tier] || TIER.other).c).attr('fill-opacity', 0.85)
.attr('stroke', '#0a0a0f').attr('stroke-width', 0.6);
leaves.append('text').attr('text-anchor', 'middle').attr('dy', '.35em')
.attr('font-size', d => Math.max(7, Math.min(d.r * 0.45, 12)))
.attr('fill', '#0a0a0f')
.text(d => d.r > 14 ? ((d.data.flag ? d.data.flag + ' ' : '') + _shortDom(d.data.domain)) : (d.r > 8 ? _shortDom(d.data.domain) : ''));
leaves.append('title').text(d => d.data.domain + ' — ~' + Math.round(d.data.value) + ' hits');
}
// ─── focus / detail panel ───
function focusNode(node, linkSel) {
if (node.kind !== 'tracker') { ndEl.hidden = true; return; }