WIP: basic Graph based on d3JS's disjoint force directed graph.

Code taken from: https://observablehq.com/@d3/disjoint-force-directed-graph
This commit is contained in:
Sergio Martínez Portela 2022-05-16 00:06:37 +02:00
parent ae07c563a8
commit 0073dd8b84
2 changed files with 239 additions and 0 deletions

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
import html import html
import logging import logging
import os import os
@ -51,6 +52,7 @@ def main(src_top, dest_top):
files_generated = 0 files_generated = 0
os.makedirs(dest_top, exist_ok=True) os.makedirs(dest_top, exist_ok=True)
graph = {}
for doc in docs: for doc in docs:
relpath = os.path.relpath(doc.path, src_top) relpath = os.path.relpath(doc.path, src_top)
changed = False changed = False
@ -106,10 +108,47 @@ def main(src_top, dest_top):
for headline in headlines: for headline in headlines:
endpath = os.path.join(dest_top, headline.id + ".node.html") endpath = os.path.join(dest_top, headline.id + ".node.html")
links = []
for l in headline.get_links():
if l.value.startswith('http://') or l.value.startswith('https://'):
pass # Ignore for now, external URL
elif l.value.startswith('id:'):
links.append({'target': l.value})
elif l.value.startswith('attachment:'):
pass # Ignore, attachment
elif l.value.startswith('* '):
pass # Ignore, internal
elif l.value.startswith('./'):
pass # TODO: Properly handle
else:
raise NotImplementedError('On document {}, link to {}'.format(doc.path, l.value))
if headline.parent:
if isinstance(headline.parent, org_rw.Headline):
links.append({
"target": headline.parent.id,
"relation": "contained-in"
})
graph[headline.id] = {
"title": headline.title.strip(),
"links": links,
"depth": headline.depth,
}
with open(endpath, "wt") as f: with open(endpath, "wt") as f:
f.write(as_document(render(headline, doc, headlineLevel=0))) f.write(as_document(render(headline, doc, headlineLevel=0)))
files_generated += 1 files_generated += 1
graphpath = os.path.join(dest_top, "graph.json")
graph_explorer_path = os.path.join(dest_top, "graph.html")
with open(graphpath, "wt") as f:
json.dump(obj=graph, fp=f, indent=2)
graph_explorer_path = os.path.join(dest_top, "graph.html")
with open(graph_explorer_path, 'wt') as f:
with open(os.path.join(os.path.dirname(os.path.abspath(dest_top)), 'static', 'graph_explorer.html'), 'rt') as template:
source = template.read()
f.write(source.replace('<!-- REPLACE_THIS_WITH_GRAPH -->',
json.dumps(graph)))
logging.info("Generated {} files".format(files_generated)) logging.info("Generated {} files".format(files_generated))

200
static/graph_explorer.html Normal file
View File

@ -0,0 +1,200 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Graph Explorer</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script type='text/javascript'>
var NODE_GRAPH=<!-- REPLACE_THIS_WITH_GRAPH -->;
</script>
<script>
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/disjoint-force-directed-graph
function ForceGraph({
nodes, // an iterable of node objects (typically [{id}, …])
links // an iterable of link objects (typically [{source, target}, …])
}, {
nodeId = d => d.id, // given d in nodes, returns a unique identifier (string)
nodeGroup, // given d in nodes, returns an (ordinal) value for color
nodeGroups, // an array of ordinal values representing the node groups
nodeTitle, // given d in nodes, a title string
nodeFill = "currentColor", // node stroke fill (if not using a group color encoding)
nodeStroke = "#fff", // node stroke color
nodeStrokeWidth = 1.5, // node stroke width, in pixels
nodeStrokeOpacity = 1, // node stroke opacity
nodeRadius = 5, // node radius, in pixels
nodeStrength,
linkSource = ({source}) => source, // given d in links, returns a node identifier string
linkTarget = ({target}) => target, // given d in links, returns a node identifier string
linkStroke = "#999", // link stroke color
linkStrokeOpacity = 0.6, // link stroke opacity
linkStrokeWidth = 1.5, // given d in links, returns a stroke width in pixels
linkStrokeLinecap = "round", // link stroke linecap
linkStrength,
colors = d3.schemeTableau10, // an array of color strings, for the node groups
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
invalidation // when this promise resolves, stop the simulation
} = {}) {
// Compute values.
const N = d3.map(nodes, nodeId).map(intern);
const LS = d3.map(links, linkSource).map(intern);
const LT = d3.map(links, linkTarget).map(intern);
if (nodeTitle === undefined) nodeTitle = (_, i) => N[i];
const T = nodeTitle == null ? null : d3.map(nodes, nodeTitle);
const G = nodeGroup == null ? null : d3.map(nodes, nodeGroup).map(intern);
const W = typeof linkStrokeWidth !== "function" ? null : d3.map(links, linkStrokeWidth);
// Replace the input nodes and links with mutable objects for the simulation.
nodes = d3.map(nodes, (_, i) => ({id: N[i]}));
links = d3.map(links, (_, i) => ({source: LS[i], target: LT[i]}));
// Compute default domains.
if (G && nodeGroups === undefined) nodeGroups = d3.sort(G);
// Construct the scales.
const color = nodeGroup == null ? null : d3.scaleOrdinal(nodeGroups, colors);
// Construct the forces.
const forceNode = d3.forceManyBody();
const forceLink = d3.forceLink(links).id(({index: i}) => N[i]);
if (nodeStrength !== undefined) forceNode.strength(nodeStrength);
if (linkStrength !== undefined) forceLink.strength(linkStrength);
const simulation = d3.forceSimulation(nodes)
.force("link", forceLink)
.force("charge", forceNode)
.force("x", d3.forceX())
.force("y", d3.forceY())
.on("tick", ticked);
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; height: intrinsic;");
const link = svg.append("g")
.attr("stroke", linkStroke)
.attr("stroke-opacity", linkStrokeOpacity)
.attr("stroke-width", typeof linkStrokeWidth !== "function" ? linkStrokeWidth : null)
.attr("stroke-linecap", linkStrokeLinecap)
.selectAll("line")
.data(links)
.join("line");
if (W) link.attr("stroke-width", ({index: i}) => W[i]);
const node = svg.append("g")
.attr("fill", nodeFill)
.attr("stroke", nodeStroke)
.attr("stroke-opacity", nodeStrokeOpacity)
.attr("stroke-width", nodeStrokeWidth)
.selectAll("circle")
.data(nodes)
.join("circle")
.attr("r", nodeRadius)
.call(drag(simulation));
if (G) node.attr("fill", ({index: i}) => color(G[i]));
if (T) node.append("title").text(({index: i}) => T[i]);
// Handle invalidation.
if (invalidation != null) invalidation.then(() => simulation.stop());
function intern(value) {
return value !== null && typeof value === "object" ? value.valueOf() : value;
}
function ticked() {
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);
}
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
return Object.assign(svg.node(), {scales: {color}});
}
</script>
<style>
body, html {
padding: 0;
margin: 0;
}
#graph_holder {
padding: 0;
margin: 0;
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<div id="graph_holder">
</div>
<script>
var edges = [];
for (var source of Object.keys(NODE_GRAPH)) {
for (var target of NODE_GRAPH[source].links) {
var node_id = target.target;
if (node_id.startsWith('id:')) {
node_id = node_id.substring(3);
}
if (NODE_GRAPH[node_id] === undefined) {
continue;
}
edges.push({source, target: node_id});
}
}
var graph = {
nodes: Object.keys(NODE_GRAPH),
links: edges,
};
var holder = document.getElementById('graph_holder');
var chart = ForceGraph(graph, {
nodeId: d => d,
nodeGroup: d => Math.random() * 4 | 0,
nodeTitle: d => `${NODE_GRAPH[d].title}`,
nodeRadius: d => Math.max(1, 4 - NODE_GRAPH[d.id].depth) * 3,
width: holder.clientWidth,
height: holder.clientHeight,
linkStrokeWidth: 0.5,
linkStrength: 1,
// invalidation // a promise to stop the simulation when the cell is re-run
});
holder.appendChild(chart);
</script>
</body>
</html>