336 lines
10 KiB
JavaScript
336 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() {
|
|
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();
|
|
}
|
|
}
|