From de62a5da0225b79fcf2460727bb7d765f8be2426 Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Sun, 12 Apr 2026 16:48:43 +0200 Subject: Initial implementation --- src/causalloop.js | 312 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 src/causalloop.js (limited to 'src/causalloop.js') 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 + * - connections: Object> + * - state: Object + */ + 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 ` +
+
+ ${text} +
+
`; + }, + + // Starts a download of the image. + download(filename, text) { + const svgWithNamespace = text.replace( + " + 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(` +
+ + `); + $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; -- cgit