diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-04-12 16:48:43 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-04-14 12:36:48 +0200 |
| commit | de62a5da0225b79fcf2460727bb7d765f8be2426 (patch) | |
| tree | 042c7cf1ee26f333df2081e711dc99ba6b599bae /src/causalloop.js | |
Initial implementation
Diffstat (limited to 'src/causalloop.js')
| -rw-r--r-- | src/causalloop.js | 312 |
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; |