/* Fingerprint Clusters — D3 packed circles grouping devices by behavior */ function renderFingerprintClusters(selector, apiUrl) { const container = document.querySelector(selector); const tooltip = createTooltip(); const { width, height } = getVizSize(container); fetch(apiUrl + '?hours=24') .then(r => r.json()) .then(data => { if (!data.clusters || data.clusters.length === 0) { container.innerHTML = '
No active devices in the last 24 hours
'; return; } // Build hierarchy for d3.pack const hierarchy = { name: 'root', children: data.clusters.map(c => ({ name: c.label, value: c.device_count, cluster: c, })), }; const activityColor = d3.scaleOrdinal() .domain(['Low', 'Medium', 'High']) .range(['#495057', '#f59f00', '#ff6b6b']); const root = d3.hierarchy(hierarchy) .sum(d => d.value || 0) .sort((a, b) => b.value - a.value); d3.pack() .size([width, height]) .padding(8)(root); const svg = d3.select(selector) .append('svg') .attr('viewBox', `0 0 ${width} ${height}`) .attr('preserveAspectRatio', 'xMidYMid meet'); const leaves = svg.selectAll('g') .data(root.leaves()) .join('g') .attr('transform', d => `translate(${d.x},${d.y})`); leaves.append('circle') .attr('class', 'cluster-circle') .attr('r', d => d.r) .attr('fill', d => activityColor(d.data.cluster.activity)) .on('mouseover', (event, d) => { const c = d.data.cluster; tooltip.show( `${c.label}
` + `Devices: ${c.device_count}
` + `Avg probe rate: ${c.centroid.probe_rate}/device
` + `Avg sighting rate: ${c.centroid.sighting_rate}/device
` + `Avg RSSI: ${c.centroid.avg_rssi !== null ? c.centroid.avg_rssi + ' dBm' : 'N/A'}`, event ); d3.select(event.target).attr('fill-opacity', 1).attr('stroke', '#fff'); }) .on('mousemove', (event) => { tooltip.move(event); }) .on('mouseout', (event) => { tooltip.hide(); d3.select(event.target).attr('fill-opacity', 0.7).attr('stroke', null); }); // Labels for circles big enough leaves.append('text') .attr('class', 'cluster-label') .attr('dy', '-0.3em') .text(d => { if (d.r < 25) return ''; const name = d.data.cluster.vendor; const maxChars = Math.floor(d.r * 2 / 7); return name.length > maxChars ? name.slice(0, maxChars - 1) + '\u2026' : name; }); leaves.append('text') .attr('class', 'cluster-label') .attr('dy', '1em') .style('font-size', '10px') .style('opacity', 0.8) .text(d => d.r >= 20 ? d.data.cluster.device_count : ''); // Legend const legend = svg.append('g') .attr('transform', `translate(${width - 140}, 20)`); ['Low', 'Medium', 'High'].forEach((level, i) => { const g = legend.append('g') .attr('transform', `translate(0, ${i * 22})`); g.append('circle') .attr('r', 6) .attr('fill', activityColor(level)); g.append('text') .attr('x', 14) .attr('y', 4) .attr('fill', '#adb5bd') .style('font-size', '12px') .text(level + ' Activity'); }); }) .catch(err => { container.innerHTML = `
Failed to load data: ${err.message}
`; }); }