Last active
January 5, 2022 20:15
-
-
Save timkpaine/553ef25920e6bcb76863d05ff35e0dff to your computer and use it in GitHub Desktop.
Coinbase Trading Pairs Graph (d3 Edge Bundling)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
license: apache-2.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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