aboutsummaryrefslogtreecommitdiff
path: root/src/causalloop.js
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-04-12 16:48:43 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-04-14 12:36:48 +0200
commitde62a5da0225b79fcf2460727bb7d765f8be2426 (patch)
tree042c7cf1ee26f333df2081e711dc99ba6b599bae /src/causalloop.js
Initial implementation
Diffstat (limited to 'src/causalloop.js')
-rw-r--r--src/causalloop.js312
1 files changed, 312 insertions, 0 deletions
diff --git a/src/causalloop.js b/src/causalloop.js
new file mode 100644
index 0000000..5267724
--- /dev/null
+++ b/src/causalloop.js
@@ -0,0 +1,312 @@
+import {forceSimulation, forceLink, forceCenter, forceManyBody, forceCollide } from "d3-force";
+import * as d3 from "d3";
+import DOMPurify from "isomorphic-dompurify";
+
+const internals = {
+ kStyleSheet: "/plugins/causalloop/causalloop.css",
+ kDimensions: {
+ full: {
+ width: 806,
+ height: 620,
+ minSize: 16,
+ initialSize: 32,
+ maxSize: 64,
+ growthFactor: 0.2,
+ },
+ thumbnail: {
+ width: 403,
+ height: 310,
+ minSize: 8,
+ initialSize: 16,
+ maxSize: 32,
+ growthFactor: 0.1,
+ }
+ },
+ kLineRe: /([^\s].+?)([+-])>\s*([^\s].+?)(?:\[((?:\d+(?:\.\d+)?)|(?:\.\d+))\])?$/,
+
+ /**
+ * Parses the causal loop diagram line by line and generates an object
+ * that looks like this:
+ * - nodes: Set<String>
+ * - connections: Object<String, Array<{value: Number, node: String}>>
+ * - state: Object<String, Number>
+ */
+ parse(text) {
+ const nodes = new Set();
+ const connections = new Set();
+ const connectionSet = new Set();
+ const lines = text.split('\n');
+ for (const line of lines) {
+ const matches = line.match(internals.kLineRe);
+ if (matches !== null) {
+ const source = matches[1]?.trim();
+ const direction = matches[2]?.trim();
+ const node = matches[3]?.trim();
+
+ // Determine the value of the connection.
+ let value = parseFloat(matches[4]);
+ if (Number.isNaN(value)) {
+ value = 1;
+ }
+ if (direction === "-") {
+ value = value * -1;
+ }
+
+ // Ensure we only have a connection in a given direction once.
+ const connectionKey = source + node;
+ if (!source || !node || connectionSet.has(connectionKey)) {
+ continue;
+ }
+ connectionSet.add(connectionKey);
+
+ nodes.add(source);
+ nodes.add(node);
+ connections[source] = (connections[source]?.push({value, node}) && connections[source]) || [{value, node}];
+ }
+ }
+ const state = Object.fromEntries(nodes.keys().map(key => [key, 1]));
+ return {
+ nodes,
+ connections,
+ state
+ }
+ },
+ renderToSVG($element, parsedDiagram, dimensions) {
+ const nodes = [...parsedDiagram.nodes].map(id => ({ id }));
+ const sizeFor = (id) => (Math.max(
+ dimensions.minSize,
+ Math.min(
+ dimensions.maxSize,
+ dimensions.initialSize +
+ parsedDiagram.state[id] * dimensions.growthFactor
+ )
+ ));
+ const links = Object.entries(parsedDiagram.connections)
+ .flatMap(([source, links]) =>
+ links.map(({ node: target, value }) => ({ source, target, value })));
+ const svg = d3.select($element).attr('viewBox', [0, 0, dimensions.width, dimensions.height]);
+ svg.selectAll('*').remove();
+ const link = svg.append('g').attr('stroke', '#999')
+ .selectAll('line')
+ .data(links)
+ .join('line');
+ const node = svg.append('g')
+ .selectAll('g')
+ .data(nodes)
+ .join('g');
+ const circle = node
+ .append('circle')
+ .attr('fill', '#fff')
+ .attr('stroke', '#333');
+ node
+ .append('text')
+ .attr('text-anchor', 'middle')
+ .attr('dy', '.35em')
+ .text(d => d.id);
+
+ const simulation = forceSimulation(nodes)
+ .force('link', forceLink(links).id(d => d.id).distance(120))
+ .force('charge', forceManyBody().strength(-400))
+ .force('center', forceCenter(dimensions.width / 2, dimensions.height / 2))
+ .force('collide', forceCollide().radius(d => sizeFor(d.id) + 4))
+ .on('tick', () => {
+ circle.attr('r', d => sizeFor(d.id));
+ 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('transform', d => `translate(${d.x},${d.y})`);
+ });
+
+ return {
+ update() { simulation.alpha(0.3).restart(); }
+ };
+ },
+
+ // Returns HTML that shows a message in the wiki.
+ message(text) {
+ return `
+ <div class="viewer" data-item="viewer" style="width:98%">
+ <div style="width:80%; padding:8px; color:gray; background-color:#eee; margin:0 auto; text-align:center">
+ <i>${text}</i>
+ </div>
+ </div>`;
+ },
+
+ // Starts a download of the image.
+ download(filename, text) {
+ const svgWithNamespace = text.replace(
+ "<svg",
+ '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"',
+ );
+ const svgContent = '<?xml version="1.0" encoding="UTF-8"?>\n' + svgWithNamespace;
+ const blob = new Blob([svgContent], { type: "image/svg+xml" });
+ const url = URL.createObjectURL(blob);
+ const element = document.createElement("a");
+ element.setAttribute("href", url);
+ element.setAttribute("download", filename);
+ element.style.display = "none";
+ document.body.appendChild(element);
+ element.click();
+ document.body.removeChild(element);
+ URL.revokeObjectURL(url);
+ },
+
+ // Replaces the image in a window. Intended for use in a dialog.
+ replaceImage(window, imageData) {
+ try {
+ const $container = window.document.querySelector("main");
+ $container.innerHTML = imageData;
+ } catch (error) {
+ console.error(
+ "causalloop: Could not replace image. The DOM wasn't ready!",
+ error,
+ );
+ }
+ },
+};
+
+/**
+ * Runs once for each block. Do setup for the block before rendering.
+ */
+const emit = function emit($item) {
+ const hasStyleSheet = [...document.styleSheets].some((styleSheet) =>
+ styleSheet.href?.endsWith(internals.kStyleSheet),
+ );
+
+ if (!hasStyleSheet) {
+ console.log("Adding causalloop stylesheet.");
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = internals.kStyleSheet;
+ link.type = "text/css";
+ document.getElementsByTagName("head")[0].appendChild(link);
+ }
+
+ return $item.append(internals.message("Loading Causal Loop Diagram"));
+};
+
+/**
+ * Bind to events on the rendered block. Called once per block.
+ * Convention is that double click opens the editor.
+ *
+ * @param {JQuery} $item the HTML element representing the block.
+ * @param {Object} item the configuration for that particular object.
+ */
+const bind = async function bind($item, item) {
+ $item.on("dblclick", () => {
+ return wiki.textEditor($item, item);
+ });
+
+ $item.on("click", "a", async (event) => {
+ const { currentTarget } = event;
+ const action = currentTarget.dataset?.action;
+
+ if (!action) {
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+
+ switch (action) {
+ case "download":
+ {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ const imageData = internals.renderToSVG(
+ svg,
+ internals.parse(item.text),
+ internals.kDimensions.full,
+ );
+ const slug = $item.parents(".page").attr("id");
+ internals.download(`${slug}.svg`, imageData);
+ }
+ break;
+ case "zoom":
+ {
+ const shouldOpenStandalone = !!event.shiftKey;
+ const target = shouldOpenStandalone ? "_blank" : "causalloop";
+ const dialog = window.open(
+ "/plugins/causalloop/dialog/#",
+ target,
+ "popup,height=600,width=800",
+ );
+
+ if (!dialog || dialog.closed) {
+ console.error("causalloop: Failed to open dialog.");
+ return;
+ }
+
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ const imageData = internals.renderToSVG(
+ svg,
+ internals.parse(item.text),
+ internals.kDimensions.full,
+ );
+
+ // We *MUST* check both readyState and href, because
+ // readyState will be complete when it first loads,
+ // and this will be an empty window.
+ if (
+ dialog.document.readyState === "complete" &&
+ dialog.document.location.href.includes(
+ "/plugins/causalloop/dialog/",
+ )
+ ) {
+ internals.replaceImage(dialog, imageData);
+ } else {
+ dialog.addEventListener(
+ "load",
+ () => {
+ internals.replaceImage(dialog, imageData);
+ },
+ { once: true },
+ );
+ }
+ }
+ break;
+ }
+ });
+
+ try {
+ const $element = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ internals.renderToSVG(
+ $element,
+ internals.parse(item.text),
+ internals.kDimensions.thumbnail,
+ );
+ $item.find(".viewer").html(`
+ <article class="causalloop thumbnail"></article>
+ <nav class="actions">
+ <menu>
+ <li><a href="#" data-action="download" title="Download"><img width="18" height="18" alt="download" src='data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="grey"><g><rect fill="none" height="24" width="24"/></g><g><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g></svg>'></a></li>
+ <li><a href="#" data-action="zoom" title="Zoom"><img width="18" height="18" alt="toggle zoom" src='data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24"><g><rect fill="none" height="24" width="24"/></g><g><g><g><path fill="grey" d="M15,3l2.3,2.3l-2.89,2.87l1.42,1.42L18.7,6.7L21,9V3H15z M3,9l2.3-2.3l2.87,2.89l1.42-1.42L6.7,5.3L9,3H3V9z M9,21 l-2.3-2.3l2.89-2.87l-1.42-1.42L5.3,17.3L3,15v6H9z M21,15l-2.3,2.3l-2.87-2.89l-1.42,1.42l2.89,2.87L15,21h6V15z"/></g></g></g></svg>'></a></li>
+ </menu>
+ </nav>
+ `);
+ $item.find(".causalloop").append($element);
+
+ $item.find('svg text:gt(7)').on('click', function(event) {
+ const title = this.innerHTML;
+ let $page = $item.parents('.page')
+ wiki.pageHandler.context = wiki.lineup.atKey($page.data('key')).getContext()
+ wiki.doInternalLink(title, event.shiftKey ? null : $page)
+ });
+ } catch (err) {
+ console.log("Failed to parse causal loop diagram: ", err);
+ $item.html(internals.message(err.message));
+ }
+};
+
+/**
+ * On load, attempt to add the emit and bind functions to window, as these
+ * are called by the wiki plugin manager.
+ */
+if (typeof window !== "undefined" && window !== null) {
+ if (!window.plugins.causalloop) {
+ window.plugins.causalloop = { emit, bind };
+ }
+}
+
+const expand = function (text) {
+ return DOMPurify.sanitize(text);
+};
+
+export const causalloop = typeof window === "undefined" ? { expand } : undefined;