/* 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}
`;
});
}