Skip to content

Commit

Permalink
create a graph of all pages
Browse files Browse the repository at this point in the history
- the plugin that calculates back-links between pages now uses the same
information to create `pages_graph.json` which
is then viewable as "Graph View" menu option at /graph
  • Loading branch information
kouloumos committed Mar 24, 2023
1 parent f48b1e3 commit e714d3a
Show file tree
Hide file tree
Showing 4 changed files with 343 additions and 0 deletions.
1 change: 1 addition & 0 deletions _data/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ menu:
- {name: 'Home', url: ''}
- {name: 'Meetings', url: 'meetings/'}
- {name: 'Topics', url: 'topics/'}
- {name: 'Graph View', url: 'graph/'}
- {name: 'Code of Conduct', url: 'code-of-conduct/'}
- {name: 'Hosting', url: 'hosting/'}
308 changes: 308 additions & 0 deletions _includes/pages_graph.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
<!-- This file is from https://github.com/maximevaillancourt/digital-garden-jekyll-template -->
<style>
.links line {
stroke: #ccc;
opacity: 0.5;
}

.nodes circle {
cursor: pointer;
fill: #8b88e6;
transition: all 0.15s ease-out;
}

.text text {
cursor: pointer;
fill: #333;
text-shadow: -1px -1px 0 #fafafabb, 1px -1px 0 #fafafabb, -1px 1px 0 #fafafabb, 1px 1px 0 #fafafabb;
}

.nodes [active],
.text [active] {
cursor: pointer;
fill: black;
}

.inactive {
opacity: 0.1;
transition: all 0.15s ease-out;
}

#graph-wrapper {
background: #fcfcfc;
border-radius: 4px;
height: auto;
}
</style>

<div id="graph-wrapper">
<script>
window.addEventListener("load", loadGraph);

function loadGraph() {
var oScript = document.createElement("script");
oScript.src = "https://cdnjs.cloudflare.com/ajax/libs/d3/5.16.0/d3.min.js";
oScript.crossOrigin = 'anonymous';
oScript.integrity =
"sha512-FHsFVKQ/T1KWJDGSbrUhTJyS1ph3eRrxI228ND0EGaEp6v4a/vGwPWd3Dtd/+9cI7ccofZvl/wulICEurHN1pg==";
document.body.appendChild(oScript);
oScript.onload = () => {
const MINIMAL_NODE_SIZE = 8;
const MAX_NODE_SIZE = 12;
const ACTIVE_RADIUS_FACTOR = 1.5;
const STROKE = 1;
const FONT_SIZE = 16;
const TICKS = 200;
const FONT_BASELINE = 40;
const MAX_LABEL_LENGTH = 50;

const graphData = {% include pages_graph.json %}
let nodesData = graphData.nodes;
let linksData = graphData.edges;

const nodeSize = {};

const updateNodeSize = () => {
nodesData.forEach((el) => {
let weight =
3 *
Math.sqrt(
linksData.filter((l) => l.source.id === el.id || l.target.id === el.id)
.length + 1
);
if (weight < MINIMAL_NODE_SIZE) {
weight = MINIMAL_NODE_SIZE;
} else if (weight > MAX_NODE_SIZE) {
weight = MAX_NODE_SIZE;
}
nodeSize[el.id] = weight;
});
};

const onClick = (d) => {
window.location = d.path
};

const onMouseover = function (d) {
const relatedNodesSet = new Set();
linksData
.filter((n) => n.target.id == d.id || n.source.id == d.id)
.forEach((n) => {
relatedNodesSet.add(n.target.id);
relatedNodesSet.add(n.source.id);
});

node.attr("class", (node_d) => {
if (node_d.id !== d.id && !relatedNodesSet.has(node_d.id)) {
return "inactive";
}
return "";
});

link.attr("class", (link_d) => {
if (link_d.source.id !== d.id && link_d.target.id !== d.id) {
return "inactive";
}
return "";
});

link.attr("stroke-width", (link_d) => {
if (link_d.source.id === d.id || link_d.target.id === d.id) {
return STROKE * 4;
}
return STROKE;
});
text.attr("class", (text_d) => {
if (text_d.id !== d.id && !relatedNodesSet.has(text_d.id)) {
return "inactive";
}
return "";
});
};

const onMouseout = function (d) {
node.attr("class", "");
link.attr("class", "");
text.attr("class", "");
link.attr("stroke-width", STROKE);
};

const sameNodes = (previous, next) => {
if (next.length !== previous.length) {
return false;
}

const map = new Map();
for (const node of previous) {
map.set(node.id, node.label);
}

for (const node of next) {
const found = map.get(node.id);
if (!found || found !== node.title) {
return false;
}
}

return true;
};

const sameEdges = (previous, next) => {
if (next.length !== previous.length) {
return false;
}

const set = new Set();
for (const edge of previous) {
set.add(`${edge.source.id}-${edge.target.id}`);
}

for (const edge of next) {
if (!set.has(`${edge.source.id}-${edge.target.id}`)) {
return false;
}
}

return true;
};

const graphWrapper = document.getElementById('graph-wrapper')
const element = document.createElementNS("http://www.w3.org/2000/svg", "svg");
element.setAttribute("width", graphWrapper.getBoundingClientRect().width);
element.setAttribute("height", window.innerHeight * 0.8);
graphWrapper.appendChild(element);

const reportWindowSize = () => {
element.setAttribute("width", window.innerWidth);
element.setAttribute("height", window.innerHeight);
};

window.onresize = reportWindowSize;

const svg = d3.select("svg");
const width = Number(svg.attr("width"));
const height = Number(svg.attr("height"));
let zoomLevel = 1;

const simulation = d3
.forceSimulation(nodesData)
.force("forceX", d3.forceX().x(width / 2))
.force("forceY", d3.forceY().y(height / 2))
.force("charge", d3.forceManyBody())
.force(
"link",
d3
.forceLink(linksData)
.id((d) => d.id)
.distance(70)
)
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(80))
.stop();

const g = svg.append("g");
let link = g.append("g").attr("class", "links").selectAll(".link");
let node = g.append("g").attr("class", "nodes").selectAll(".node");
let text = g.append("g").attr("class", "text").selectAll(".text");

const resize = () => {
if (d3.event) {
const scale = d3.event.transform;
zoomLevel = scale.k;
g.attr("transform", scale);
}

const zoomOrKeep = (value) => (zoomLevel >= 1 ? value / zoomLevel : value);

const font = Math.max(Math.round(zoomOrKeep(FONT_SIZE)), 1);

text.attr("font-size", (d) => font);
text.attr("y", (d) => d.y - zoomOrKeep(FONT_BASELINE) + 8);
link.attr("stroke-width", zoomOrKeep(STROKE));
node.attr("r", (d) => {
return zoomOrKeep(nodeSize[d.id]);
});
svg
.selectAll("circle")
.filter((_d, i, nodes) => d3.select(nodes[i]).attr("active"))
.attr("r", (d) => zoomOrKeep(ACTIVE_RADIUS_FACTOR * nodeSize[d.id]));
};

const ticked = () => {
node.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
text
.attr("x", (d) => d.x)
.attr("y", (d) => d.y - (FONT_BASELINE - nodeSize[d.id]) / zoomLevel);
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);
};

const restart = () => {
updateNodeSize();
node = node.data(nodesData, (d) => d.id);
node.exit().remove();
node = node
.enter()
.append("circle")
.attr("r", (d) => {
return nodeSize[d.id];
})
.on("click", onClick)
.on("mouseover", onMouseover)
.on("mouseout", onMouseout)
.merge(node);

link = link.data(linksData, (d) => `${d.source.id}-${d.target.id}`);
link.exit().remove();
link = link.enter().append("line").attr("stroke-width", STROKE).merge(link);

text = text.data(nodesData, (d) => d.label);
text.exit().remove();
text = text
.enter()
.append("text")
.text((d) => shorten(d.label.replace(/_*/g, ""), MAX_LABEL_LENGTH))
.attr("font-size", `${FONT_SIZE}px`)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "central")
.on("click", onClick)
.on("mouseover", onMouseover)
.on("mouseout", onMouseout)
.merge(text);

node.attr("active", (d) => isCurrentPath(d.path) ? true : null);
text.attr("active", (d) => isCurrentPath(d.path) ? true : null);

simulation.nodes(nodesData);
simulation.force("link").links(linksData);
simulation.alpha(1).restart();
simulation.stop();

for (let i = 0; i < TICKS; i++) {
simulation.tick();
}

ticked();
};

const zoomHandler = d3.zoom().scaleExtent([0.2, 3]).on("zoom", resize);

zoomHandler(svg);
restart();

function isCurrentPath(notePath) {
return window.location.pathname.includes(notePath)
}

function shorten(str, maxLen, separator = ' ') {
if (str.length <= maxLen) return str;
return str.substr(0, str.lastIndexOf(separator, maxLen)) + '...';
}
}
}
</script>
</div>

24 changes: 24 additions & 0 deletions _plugins/bidirectional_links_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# Generators run after Jekyll has made an inventory of the existing content, and before the site is generated.
class BidirectionalLinksGenerator < Jekyll::Generator
def generate(site)
graph_nodes = []
graph_edges = []

all_notes = site.collections['topics'].docs
all_posts = site.posts.docs
Expand Down Expand Up @@ -107,7 +109,29 @@ def generate(site)
# Edges: Jekyll
current_page.data['backlinks'] = pages_linking_to_current_page

# Use the calculated data to add nodes and edges to pages_graph.json from which
# we can then render a graph of all the pages and how they are connected

# Nodes: Graph
graph_nodes << {
id: page_id_from_page(current_page),
path: "#{site.baseurl}#{current_page.url}",
label: current_page.data['title'],
} unless current_page.path.include?('_topics/index.html')

# Edges: Graph
pages_linking_to_current_page.each do |n|
graph_edges << {
source: page_id_from_page(n["doc"]),
target: page_id_from_page(current_page),
}
end
end

File.write('_includes/pages_graph.json', JSON.dump({
edges: graph_edges,
nodes: graph_nodes,
}))
end

def page_id_from_page(page)
Expand Down
10 changes: 10 additions & 0 deletions graph.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
layout: default
title: Graph
permalink: /graph/
---


<p>Graph visualization of all the pages and their links.</p>

{% include pages_graph.html %}

0 comments on commit e714d3a

Please sign in to comment.