Skip to content

Instantly share code, notes, and snippets.

@timkpaine
Last active January 5, 2022 20:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save timkpaine/553ef25920e6bcb76863d05ff35e0dff to your computer and use it in GitHub Desktop.
Save timkpaine/553ef25920e6bcb76863d05ff35e0dff to your computer and use it in GitHub Desktop.
Coinbase Trading Pairs Graph (d3 Edge Bundling)
license: apache-2.0
html, body {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
div.graph-parent {
height: 100%;
flex: 1;
}
.node {
stroke: #ffffff;
stroke-weight: 1px;
}
.link {
fill: none;
stroke: #ccc;
stroke-weight: 1px;
z-index: 1;
}
.highlight {
fill: none;
stroke: red;
stroke-weight: 2px;
stroke-opacity: 1.0;
z-index: 100;
}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
</head>
<body>
<div id="graph-parent"></div>
<script src="http://d3js.org/d3.v7.min.js" charset="utf-8"></script>
<link rel="stylesheet" href="index.css">
<script type="module" src="./index.js"></script>
</body>
</html>
// https://observablehq.com/@d3/hierarchical-edge-bundling
// Draws an arc diagram for the provided undirected graph
function drawGraph(domNode, nodes, edges) {
// use the full size parent node
const height = domNode.parentNode.offsetHeight;
const width = domNode.parentNode.offsetWidth;
const radius = Math.min(height, width) / 2 - 50;
// create svg image
const svg = d3
.select(domNode)
.append("svg")
.attr("id", () => "graph")
.attr("width", width)
.attr("height", height);
// create plot area within svg image
const plot = svg
.append("g")
.attr("id", "plot")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("transform", `translate(${width / 2}, ${height / 2})`);
// use to scale node index to theta value
const scale = d3
.scaleLinear()
.domain([0, nodes.length])
.range([0, 2 * Math.PI]);
// calculate theta for each node
nodes.forEach((d, i) => {
// calculate polar coordinates
d.theta = scale(i);
d.radial = radius - 20;
// convert to cartesian coordinates
d.x = d.radial * Math.sin(d.theta);
d.y = d.radial * Math.cos(d.theta);
});
const link = plot
.selectAll(".link")
.data(edges)
.enter()
.append("path")
.style("mix-blend-mode", "multiply")
.attr("class", "link")
.attr("d", (d) => {
const lineData = [
{
x: Math.round(d.target.x),
y: Math.round(d.target.y),
},
{
x: Math.round(d.target.x) - Math.round(d.target.x) / 3,
y: Math.round(d.target.y) - Math.round(d.target.y) / 3,
},
{
x: Math.round(d.source.x) - Math.round(d.source.x) / 3,
y: Math.round(d.source.y) - Math.round(d.source.y) / 3,
},
{
x: Math.round(d.source.x),
y: Math.round(d.source.y),
},
];
return `M${lineData[0].x},${lineData[0].y}C${lineData[1].x},${lineData[1].y},${lineData[2].x},${lineData[2].y},${lineData[3].x},${lineData[3].y} `;
})
.each(function (d) {
d.path = this;
});
function overed(event, d) {
link.style("mix-blend-mode", null);
d3.select(this).attr("font-weight", "bold");
d3.selectAll(d.paths.map((d) => d.path))
.attr("class", "highlight")
.raise();
d3.selectAll(d.connectedNodes.map((d) => d.text))
.attr("fill", "red")
.attr("font-weight", "bold");
}
function outed(event, d) {
link.style("mix-blend-mode", "multiply");
d3.select(this).attr("font-weight", null);
d3.selectAll(d.paths.map((d) => d.path)).attr("class", "link");
d3.selectAll(d.connectedNodes.map((d) => d.text))
.attr("fill", null)
.attr("font-weight", null);
}
plot
.selectAll(".node")
.data(nodes)
.enter()
.append("g")
.attr(
"transform",
(d) =>
`rotate(${(-1 * (d.theta * 180)) / Math.PI + 90}) translate(${
d.radial
}, 0)`,
)
.insert("text")
.attr("data-id", (d) => d.name)
.attr("dy", "0.31em")
.attr("x", (d) => (d.theta < Math.PI ? 3 : -3))
.attr("text-anchor", (d) => (d.theta < Math.PI ? "start" : "end"))
.attr("transform", (d) => (d.theta >= Math.PI ? "rotate(180)" : null))
.text((d) => d.name)
.each(function (d) {
d.text = this;
})
.on("mouseover", overed)
.on("mouseout", outed);
}
const buildHierarchicalEdgeBundle = (rawNodes, rawEdges, domNode) => {
// purge existing graph
while (domNode.lastChild) domNode.removeChild(domNode.lastChild);
// construct nodes and edges copy for drawing
const nodes = rawNodes.map((name) => ({
name,
connectedNodes: [],
paths: [],
}));
// build temp map by name
const nodeMap = new Map();
nodes.forEach((node) => {
nodeMap.set(node.name, node);
});
console.log(nodes);
// construct edges with links to nodes
const edges = rawEdges.map((edge) => {
// construct path
const path = {
source: nodeMap.get(edge.base_currency),
target: nodeMap.get(edge.quote_currency),d3
};
// shove nodes into each node's connections
nodeMap.get(edge.base_currency).connectedNodes.push(nodeMap.get(edge.quote_currency));
nodeMap.get(edge.quote_currency).connectedNodes.push(nodeMap.get(edge.base_currency));
// shove path into each node's paths
nodeMap.get(edge.base_currency).paths.push(path);
nodeMap.get(edge.quote_currency).paths.push(path);
// return reference to path
return path;
});
// draw pretty graph
drawGraph(domNode, nodes, edges);
};
(async () => {
// grab the dom node to build the graph under
const graphNode = document.getElementById("graph-parent");
// grab all trading pairs
const products = await (await fetch("https://api.exchange.coinbase.com/products")).json();
// reduce to all instruments
const instrumentSet = new Set();
for (const {base_currency, quote_currency} of products) {
instrumentSet.add(base_currency);
instrumentSet.add(quote_currency);
}
// put into array
const instruments = [...instrumentSet].sort();
buildHierarchicalEdgeBundle(instruments, products, graphNode);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment