trimill.xyz/flaskr/templates/projects/zzcxz_vis/index.js
2023-07-09 01:03:51 -04:00

338 lines
10 KiB
JavaScript

const TAU = 6.283185307179586;
const radius = 25;
const radiusRedirect = 15;
const colorBackground = "var(--bg-intense)";
const colorNormal = "var(--fg)";
const colorRedirect = "#8af";
const colorDone = "var(--fg-faded)";
const colorRedirectDone = "#679";
const colorRoot = "#4a5";
const colorRootDone = "#484";
const alphaInit = 0.5;
const alphaTarget = 0.1;
const linkDistance = 150;
const linkStrength = 0.1;
let nodes = [];
let links = [];
let refresh;
let transform = {x: 0, y: 0, k: 1};
let selectedNode = null;
let showLabels = true;
function getColorFor(elem) {
if(elem.root) {
if(elem.done) { return colorRootDone; }
return colorRoot;
} else if(elem.type === "normal") {
if(elem.done) { return colorDone; }
return colorNormal;
} else if(elem.type === "redirect") {
if(elem.done) { return colorRedirectDone; }
return colorRedirect;
}
}
function getRadiusFor(elem) {
if(elem.type === "normal") {
return radius;
} else if(elem.type === "redirect") {
return radiusRedirect;
}
}
// Fetch and parse a zzcxz page
async function loadPage(id) {
const url = "https://zzcxz.citrons.xyz/g/" + id + "/raw";
console.log("fetching id " + id);
return fetch(url).then(x => x.text()).then(text => {
let pageType = "normal";
const directive = text.match(/^\t#([A-Za-z]+)\s*(.*)\n?$/m);
const title = text.match(/^.+\n/m)[0];
const links = (text.match(/^[a-z]{5}:.*\n/gm) || [])
.map(line => {
return {"to": line.trim().split(":")[0], "type": "normal"};
});
if(directive) {
const kind = directive[1];
const arg = directive[2];
if(kind.toLowerCase() === "redirect") {
pageType= "redirect";
links.push({"to":arg,"type":"redirect"});
} else {
throw "Unsupported directive: #" + kind
}
}
return {
"id": id,
"type": pageType,
"title": title,
"links": links,
};
});
}
// Add a page to the network (nodes and links lists (not linked lists (at least i think not (i do not know how js lists are implemented))))
function addPage(id, x, y, root) {
loadPage(id).then(page => {
if(nodes.map(node => node.id).includes(page.id)) {
return;
}
let newLinks = [];
for(node of nodes) {
for(link of node.links) {
if(link.to === page.id) {
newLinks.push({
"source": node.id,
"target": page.id,
"type": link.type,
});
}
}
for(link of page.links) {
if(link.to === node.id) {
newLinks.push({
"source": page.id,
"target": node.id,
"type": link.type,
});
}
}
}
nodes.push({
"id": page.id,
"x": x,
"y": y,
"title": page.title,
"links": page.links,
"type": page.type,
"done": false,
"root": root,
});
for(link of newLinks) {
links.push(link);
}
refresh();
});
}
function addAll(node, x, y) {
const doneIds = nodes.map(n => n.id);
for(link of node.links) {
if(!doneIds.includes(link.to)) {
const theta = Math.random() * TAU;
const dx = 2 * radius * Math.cos(theta);
const dy = 2 * radius * Math.sin(theta);
addPage(link.to, x + dx, y + dy, false);
}
}
node.done = true;
d3.select(".node-" + node.id)
.style("fill", getColorFor);
}
function selectNode(node) {
if(selectedNode !== null) {
d3.select(".node-" + selectedNode.id)
.style("stroke", "#0000");
}
selectedNode = node;
if(selectedNode !== null) {
d3.select(".node-" + selectedNode.id)
.style("stroke", "#fff");
d3.select("#selected-node").attr("hidden", null);
d3.select("#selected-node-title").html(selectedNode.title);
d3.select("#selected-node-id").html(selectedNode.id);
d3.select("#selected-node-link").attr("href", "https://zzcxz.citrons.xyz/g/" + selectedNode.id);
} else {
d3.select("#selected-node").attr("hidden", true);
}
}
function visualize() {
document.getElementsByTagName("body")[0].style.width = "min(2000px, 90vw)";
const zoom = d3.zoom();
zoom.on("zoom", event => {
const elems = d3.select("#elems")
.attr("transform", event.transform);
transform = event.transform;
});
const svg = d3.select("#graph")
.append("svg")
.attr("id", "graph-svg")
.attr("width", "100%")
.attr("height", "100%")
.style("background-color", colorBackground)
.call(zoom);
const clientWidth = document.querySelector("#graph").clientWidth;
const clientHeight = document.querySelector("#graph").clientHeight;
d3.select("#spoiler").remove();
d3.select("#ui").attr("hidden", null);
d3.select("#expand-link").on("click", () => {
d3.select("#uiexpand").attr("hidden", true);
d3.select("#ui").attr("hidden", null);
});
d3.select("#contract-link").on("click", () => {
d3.select("#uiexpand").attr("hidden", null);
d3.select("#ui").attr("hidden", true);
});
d3.select("#load-page").on("keypress", (event) => {
if(event.keyCode == 13) {
addPage(event.target.value, (clientWidth/2-transform.x)/transform.k, (clientHeight/2-transform.y)/transform.k, true);
event.target.value = "";
}
});
d3.select("#clear-nodes").on("click", (event) => {
selectNode(null);
links.length = 0;
nodes.length = 0;
refresh();
});
d3.select("#toggle-labels").on("click", (event) => {
showLabels = !showLabels;
if(showLabels) {
d3.selectAll(".label").attr("visibility", "visible");
} else {
d3.selectAll(".label").attr("visibility", "hidden");
}
});
d3.select("body")
.on("keydown", event => {
if(event.keyCode == 27) {
selectNode(null);
}
})
.on("click", event => selectNode(null));
const marker = svg.append("svg:defs")
.selectAll(".triangle-marker")
.data([["normal", colorNormal], ["redirect", colorRedirect]])
.enter()
.append("svg:marker")
.attr("id", d => `triangle-${d[0]}`)
.attr("refX", radius+10)
.attr("refY", 5)
.attr("orient", "auto")
.attr("markerUnits", "strokeWidth")
.attr("markerWidth", 15)
.attr("markerHeight", 10)
.append("svg:polygon")
.attr("points", "0 0, 15 5, 0 10")
.attr("fill", d => d[1]);
const simulation = d3.forceSimulation().nodes(nodes);
const forceLinks = d3.forceLink(links)
.distance(linkDistance)
.strength(linkStrength)
.id((d, i) => d.id);
simulation.force("link", forceLinks)
.force("charge", d3.forceManyBody().distanceMax(256).distanceMin(1).strength(-50))
.force("collide", d3.forceCollide().radius(radius*1.4));
const elems = svg.append("g").attr("id", "elems");
const gLinks = elems.append("g").attr("id", "links");
const gNodes = elems.append("g").attr("id", "nodes");
const gLabels = elems.append("g").attr("id", "labels");
let node, link, label;
const dragDrop = d3.drag().on("start", (event, node) => {
node.fx = node.x;
node.fy = node.y;
}).on("drag", (event, node) => {
simulation.alpha(alphaInit).alphaTarget(alphaTarget).restart();
node.fx = event.x;
node.fy = event.y;
}).on("end", (event, node) => {
if (!event.active) {
simulation.alphaTarget(alphaTarget);
}
node.fx = null;
node.fy = null;
})
function tick() {
gNodes.selectAll(".node")
.attr("cx", d => d.x)
.attr("cy", d => d.y);
gLabels.selectAll(".label")
.attr("x", d => d.x)
.attr("y", d => d.y);
gLinks.selectAll(".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);
}
refresh = () => {
node = gNodes.selectAll(".node").data(nodes, d => d.id);
node.exit().remove();
node.enter()
.append("circle")
.attr("class", d => `node node-${d.id}`)
.attr("r", getRadiusFor)
.style("fill", getColorFor)
.style("stroke", "#0000")
.style("stroke-width", 2.5)
.call(dragDrop)
.on("click", (event, target) => addAll(target, target.x, target.y))
.on("contextmenu", (event, target) => { event.preventDefault(); selectNode(target); });
label = gLabels.selectAll(".label");
label = gLabels.selectAll(".label").data(nodes, d => d.id);
label.exit().remove();
label.enter()
.append("text")
.attr("class", "label")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.style("fill", colorBackground)
.style("stroke", colorNormal)
.style("stroke-width", 2.5)
.style("paint-order", "stroke")
.style("font-size", "15px")
.text(d => d.title)
.attr("pointer-events", "none")
.attr("visibility", showLabels ? "visible" : "hidden");
link = gLinks.selectAll(".link").data(links);
link.exit().remove();
link.enter()
.append("line")
.attr("class", "link")
.style("stroke", getColorFor)
.attr("marker-end", d => `url(#triangle-${d.type})`);
simulation.nodes(nodes).on("tick", tick);
simulation.force("link").initialize(links);
simulation.alpha(alphaInit).alphaTarget(alphaTarget).restart();
};
addPage("zzcxz", clientWidth/2, clientHeight/2, true);
}
window.onload = () => {
if(new URLSearchParams(window.location.search).has("skip")) {
visualize();
}
}