const linkArc = (d) => { const r = Math.hypot(d.target.x - d.source.x, d.target.y - d.source.y); return ` M${d.source.x},${d.source.y} A${r},${r} 0 0,1 ${d.target.x},${d.target.y} `; }; const drag = (simulation) => { function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragended(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; } return d3 .drag() .on("start", dragstarted) .on("drag", dragged) .on("end", dragended); }; const drawChart = (data) => { // Specify the dimensions of the chart. const width = 1600; const height = 1200; // Specify the color scale. const color = d3.scaleOrdinal(d3.schemeCategory10); // The force simulation mutates links and nodes, so create a copy // so that re-evaluating this cell produces the same result. const links = data.links.map((d) => ({ ...d })); const nodes = data.nodes.map((d) => ({ ...d })); // Create a simulation with several forces. const simulation = d3 .forceSimulation(nodes) .force( "link", d3.forceLink(links).id((d) => d.id), ) .force("charge", d3.forceManyBody()) .force("x", d3.forceX()) .force("y", d3.forceY()); // Create the SVG container. const svg = d3 .create("svg") .attr("width", width) .attr("height", height) .attr("viewBox", [-width / 2, -height / 2, width, height]) .attr("style", "max-width: 100%; height: auto;"); // Add a line for each link, and a circle for each node. const link = svg .append("g") .attr("stroke", "#999") .attr("stroke-opacity", 0.6) .selectAll("line") .data(links) .join("line") .attr("stroke-width", 1); // (d) => Math.sqrt(d.value)); const node = svg .append("g") .attr("stroke", "#fff") .attr("stroke-width", 1.5) .selectAll("circle") .data(nodes) .join("circle") .attr("r", 5) .attr("fill", (d) => color(d.group)); node.append("title").text((d) => d.id); // Add a drag behavior. node.call( d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended), ); // Set the position attributes of links and nodes each time the simulation ticks. 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); }); // Reheat the simulation when drag starts, and fix the subject position. function dragstarted(event) { if (!event.active) simulation.alphaTarget(0.3).restart(); event.subject.fx = event.subject.x; event.subject.fy = event.subject.y; } // Update the subject (dragged node) position during drag. function dragged(event) { event.subject.fx = event.x; event.subject.fy = event.y; } // Restore the target alpha so the simulation cools after dragging ends. // Unfix the subject position now that it’s no longer being dragged. function dragended(event) { if (!event.active) simulation.alphaTarget(0); event.subject.fx = null; event.subject.fy = null; } // When this cell is re-run, stop the previous simulation. (This doesn’t // really matter since the target alpha is zero and the simulation will // stop naturally, but it’s a good practice.) // invalidation.then(() => simulation.stop()); return svg.node(); }; const drawChart2 = (data) => { const width = 1600; const height = 1200; const types = Array.from(new Set(data.map((d) => d.type))); const nodes = Array.from( new Set(data.flatMap((l) => [l.source, l.target])), (id) => ({ id }), ); const links = data.map((d) => Object.create(d)); const color = d3.scaleOrdinal(types, d3.schemeCategory10); const simulation = d3 .forceSimulation(nodes) .force( "link", d3.forceLink(links).id((d) => d.id), ) .force("charge", d3.forceManyBody().strength(-400)) .force("x", d3.forceX()) .force("y", d3.forceY()); const svg = d3 .create("svg") .attr("viewBox", [-width / 2, -height / 2, width, height]) .attr("width", width) .attr("height", height) .attr("style", "max-width: 100%; height: auto; font: 12px sans-serif;"); // Per-type markers, as they don't inherit styles. svg .append("defs") .selectAll("marker") .data(types) .join("marker") .attr("id", (d) => `arrow-${d}`) .attr("viewBox", "0 -5 10 10") .attr("refX", 15) .attr("refY", -0.5) .attr("markerWidth", 6) .attr("markerHeight", 6) .attr("orient", "auto") .append("path") .attr("fill", color) .attr("d", "M0,-5L10,0L0,5"); const link = svg .append("g") .attr("fill", "none") .attr("stroke-width", 1.5) .selectAll("path") .data(links) .join("path") .attr("stroke", (d) => color(d.type)) .attr("marker-end", (d) => `url(${new URL(`#arrow-${d.type}`, location)})`); const node = svg .append("g") .attr("fill", "currentColor") .attr("stroke-linecap", "round") .attr("stroke-linejoin", "round") .selectAll("g") .data(nodes) .join("g") .call(drag(simulation)); node .append("circle") .attr("stroke", "white") .attr("stroke-width", 1.5) .attr("r", 4); node .append("text") .attr("x", 8) .attr("y", "0.31em") .text((d) => (d.id.endsWith("*") ? "*" : d.id)) .clone(true) .lower() .attr("fill", "none") .attr("stroke", "white") .attr("stroke-width", 3); simulation.on("tick", () => { link.attr("d", linkArc); node.attr("transform", (d) => `translate(${d.x},${d.y})`); }); // invalidation.then(() => simulation.stop()); return Object.assign(svg.node(), { scales: { color } }); }; const parseNodesAndLinks = (traces) => { const result = { nodes: [], links: [], }; traces.forEach((trace) => { let prevId = null; const getId = (hop) => { if (prevId === null) { return trace.origin; } if (hop.name === "*") { return `${trace.id}-${hop.number}-*`; } return hop.ip; }; trace.hops.forEach((hop) => { const id = getId(hop); // New node result.nodes.push({ id: id, group: trace.origin, radius: 8, value: hop.name || "name?", origin: trace.origin, }); if (prevId) { // New link result.links.push({ source: prevId, target: id, type: trace.origin, group: trace.origin, }); } prevId = id; }); // Last "destination" node result.nodes.push({ id: trace.id, group: trace.origin, radius: 8, value: trace.target, }); if (prevId) { // New link result.links.push({ source: prevId, target: trace.target, type: trace.origin, group: trace.origin, }); } }); // { id: ip, group: origin, radius: 2 } // { source: prev.ip, target: ip, value: latency } return result; }; async function main() { const response = await fetch("/trace/"); const traces = await response.json(); console.log("Traces:", traces); const data = parseNodesAndLinks(traces); console.log("Data:", data); const chart = drawChart2(data.links); container.append(chart); } main();