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(); } }