diff --git a/layouts/graph/single.html b/layouts/graph/single.html
new file mode 100644
index 0000000..e5775f1
--- /dev/null
+++ b/layouts/graph/single.html
@@ -0,0 +1,16 @@
+{{ define "main" }}
+
{{ .Title }}
+
+
+
+{{ .Content }}
+
+
+{{ end }}
diff --git a/static/graph/graph.js b/static/graph/graph.js
new file mode 100644
index 0000000..3bc3a1b
--- /dev/null
+++ b/static/graph/graph.js
@@ -0,0 +1,53 @@
+import { nodes, edges } from "./graph.gen.js";
+
+var container = document.getElementById("graph-container");
+var options = {
+ interaction: {
+ hover: true
+ },
+ nodes: {
+ shape: "dot",
+ size: 16,
+ },
+ physics: {
+ forceAtlas2Based: {
+ gravitationalConstant: -10,
+ centralGravity: 0.005,
+ springLength: 230,
+ springConstant: 0.18,
+ },
+ maxVelocity: 146,
+ solver: "forceAtlas2Based",
+ timestep: 0.35,
+ stabilization: { iterations: 150 },
+ },
+};
+
+var nodesDs = new vis.DataSet();
+nodesDs.add(nodes);
+var edgesDs = new vis.DataSet();
+edgesDs.add(edges);
+var network = new vis.Network(container, { nodes: nodesDs, edges: edgesDs }, options);
+
+network.on("doubleClick", function (params) {
+ params.event = "[original event]";
+ if (params.nodes.length !== 1) return;
+ window.open(nodesDs.get(params.nodes[0]).url, "_blank")
+});
+network.on("hoverNode", function (params) {
+ nodesDs.update({ id: params.node, label: nodesDs.get(params.node).name });
+});
+network.on("blurNode", function (params) {
+ nodesDs.update({ id: params.node, label: undefined });
+});
+// network.on("selectNode", function (params) {
+// for (const node of params.nodes) {
+// nodesDs.update({ id: node, label: nodesDs.get(node).name });
+// }
+// });
+// network.on("deselectNode", function (params) {
+// for (const node of params.previousSelection.nodes) {
+// if (params.nodes.some(n => n === node)) continue;
+// nodesDs.update({ id: node, label: undefined });
+// }
+// });