/* SSID Social Graph — D3 force-directed graph linking devices by shared probed SSIDs */ function renderSsidGraph(selector, apiUrl) { const container = document.querySelector(selector); const tooltip = createTooltip(); const { width, height } = getVizSize(container); fetch(apiUrl + '?hours=24&min_shared=1&limit=200') .then(r => r.json()) .then(data => { if (!data.nodes || data.nodes.length === 0) { container.innerHTML = '
No probe data available
'; return; } const color = d3.scaleOrdinal() .domain(['wifi', 'ble']) .range(['#4dabf7', '#69db7c']); const sizeScale = d3.scaleSqrt() .domain([1, d3.max(data.nodes, d => d.ssid_count) || 1]) .range([4, 16]); const svg = d3.select(selector) .append('svg') .attr('viewBox', `0 0 ${width} ${height}`) .attr('preserveAspectRatio', 'xMidYMid meet'); const simulation = d3.forceSimulation(data.nodes) .force('link', d3.forceLink(data.links).id(d => d.id).distance(80)) .force('charge', d3.forceManyBody().strength(-100)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collision', d3.forceCollide().radius(d => sizeScale(d.ssid_count) + 2)); const link = svg.append('g') .selectAll('line') .data(data.links) .join('line') .attr('class', 'graph-link') .attr('stroke-width', d => Math.max(1, d.weight)); const node = svg.append('g') .selectAll('circle') .data(data.nodes) .join('circle') .attr('class', 'graph-node') .attr('r', d => sizeScale(d.ssid_count)) .attr('fill', d => color(d.type)) .on('mouseover', (event, d) => { tooltip.show( `${d.id}
` + `Vendor: ${d.vendor}
` + `Type: ${d.type}
` + `Probed SSIDs: ${d.ssid_count}`, event ); d3.select(event.target).attr('stroke', '#fff').attr('stroke-width', 3); }) .on('mousemove', (event) => { tooltip.move(event); }) .on('mouseout', (event) => { tooltip.hide(); d3.select(event.target).attr('stroke', null).attr('stroke-width', 1.5); }) .call(drag(simulation)); // Link hover link.on('mouseover', (event, d) => { const ssids = d.shared_ssids.slice(0, 5).join(', '); const more = d.shared_ssids.length > 5 ? ` (+${d.shared_ssids.length - 5} more)` : ''; tooltip.show( `Shared SSIDs (${d.weight})
${ssids}${more}`, event ); }).on('mousemove', (event) => tooltip.move(event)) .on('mouseout', () => tooltip.hide()); simulation.on('tick', () => { link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); node .attr('cx', d => d.x) .attr('cy', d => d.y); }); function drag(sim) { return d3.drag() .on('start', (event, d) => { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) .on('end', (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }); } }) .catch(err => { container.innerHTML = `
Failed to load data: ${err.message}
`; }); }