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", kTheme: { positive: '#23C17C', negative: '#FA2B00', node: '#0F261F', halo: '#FFFFFF', }, kDimensions: { full: { width: 806, height: 620, minSize: 16, initialSize: 32, maxSize: 64, growthFactor: 0.2, collideRadius: 2, collideExtra: 25, haloWidth: 4, edgeLabelHaloWidth: 3, edgeWidth: 1.5, curveOffset: 20, linkDistance: 180, chargeStrength: -700, maxLabelWidth: 10, nodeFontSize: '14px', edgeFontSize: '11px', }, thumbnail: { width: 403, height: 310, minSize: 8, initialSize: 16, maxSize: 32, growthFactor: 0.1, collideRadius: 2, collideExtra: 10, haloWidth: 3, edgeLabelHaloWidth: 2, edgeWidth: 1, curveOffset: 12, linkDistance: 80, chargeStrength: -300, maxLabelWidth: 10, nodeFontSize: '12px', edgeFontSize: '10px', } }, 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, { width, height, collideRadius, collideExtra, haloWidth, edgeLabelHaloWidth, edgeWidth, curveOffset, linkDistance, chargeStrength, maxLabelWidth, nodeFontSize, edgeFontSize }) { const nodeId = (node) => typeof node === 'object' ? node.id : node; const { kTheme } = internals; const colorFor = (datum) => datum.value > 0 ? kTheme.positive : kTheme.negative; function wrapLabel(text) { if (text.length <= maxLabelWidth) return [text]; const words = text.split(/\s+/); const lines = []; let currentLine = words[0]; for (let i = 1; i < words.length; i++) { if (currentLine.length + 1 + words[i].length <= maxLabelWidth) { currentLine += ' ' + words[i]; } else { lines.push(currentLine); currentLine = words[i]; } } lines.push(currentLine); return lines; } const nodes = [...parsedDiagram.nodes].map(id => { const lines = wrapLabel(id); const maxLineLength = Math.max(...lines.map(line => line.length)); return { id, lines, maxLineLength }; }); const links = Object.entries(parsedDiagram.connections) .flatMap(([source, links]) => links.map(({ node: target, value }) => ({ source, target, value }))); // Build a set of bidirectional pairs for curve detection const linkKeys = new Set(links.map(link => `${nodeId(link.source)}\0${nodeId(link.target)}`)); const isBidirectional = (link) => linkKeys.has(`${nodeId(link.target)}\0${nodeId(link.source)}`); const svg = d3.select($element).attr('viewBox', [0, 0, width, height]); svg.selectAll('*').remove(); // Define arrow markers for positive and negative edges const defs = svg.append('defs'); for (const [id, color] of [['arrow-positive', kTheme.positive], ['arrow-negative', kTheme.negative]]) { defs.append('marker') .attr('id', id) .attr('viewBox', '0 0 10 6') .attr('refX', 10) .attr('refY', 3) .attr('markerWidth', 10) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,0 L10,3 L0,6 Z') .attr('fill', color); } // Edge paths const edgeGroup = svg.append('g').attr('fill', 'none'); const edge = edgeGroup.selectAll('path') .data(links) .join('path') .attr('stroke', datum => colorFor(datum)) .attr('stroke-width', edgeWidth) .attr('marker-end', datum => datum.value > 0 ? 'url(#arrow-positive)' : 'url(#arrow-negative)'); // Edge labels const edgeLabelGroup = svg.append('g') .attr('font-family', 'serif') .attr('font-size', edgeFontSize); const edgeLabel = edgeLabelGroup.selectAll('text') .data(links) .join('text') .attr('text-anchor', 'middle') .attr('dy', '-4') .attr('fill', datum => colorFor(datum)) .attr('stroke', kTheme.halo) .attr('stroke-width', edgeLabelHaloWidth) .attr('paint-order', 'stroke') .text(datum => datum.value > 0 ? 'S(+)' : 'O(-)'); // Node labels — long labels wrap into multiple lines const lineHeight = 1.2; const node = svg.append('g') .attr('font-family', 'serif') .attr('font-size', nodeFontSize) .selectAll('text') .data(nodes) .join('text') .attr('text-anchor', 'middle') .attr('fill', kTheme.node) .attr('stroke', kTheme.halo) .attr('stroke-width', haloWidth) .attr('paint-order', 'stroke') .attr('class', 'label') .attr('data-title', datum => datum.id) .each(function(datum) { const element = d3.select(this); const verticalOffset = 0.35 - (datum.lines.length - 1) * lineHeight / 2; datum.lines.forEach((line, i) => { element.append('tspan') .attr('dy', i === 0 ? `${verticalOffset}em` : `${lineHeight}em`) .text(line); }); }); const simulation = forceSimulation(nodes) .force('link', forceLink(links).id(datum => datum.id).distance(linkDistance)) .force('charge', forceManyBody().strength(chargeStrength)) .force('center', forceCenter(width / 2, height / 2)) .force('collide', forceCollide().radius(datum => datum.maxLineLength * collideRadius + collideExtra)) .on('tick', ticked); function labelPosition(datum) { const midX = (datum.source.x + datum.target.x) / 2; const midY = (datum.source.y + datum.target.y) / 2; if (!isBidirectional(datum)) return { x: midX, y: midY }; const dx = datum.target.x - datum.source.x; const dy = datum.target.y - datum.source.y; const length = Math.sqrt(dx * dx + dy * dy) || 1; return { x: midX + -(dy / length) * curveOffset, y: midY + (dx / length) * curveOffset }; } function ticked() { // Clamp nodes inside the viewBox const padding = 40; nodes.forEach(datum => { datum.x = Math.max(padding, Math.min(width - padding, datum.x)); datum.y = Math.max(padding, Math.min(height - padding, datum.y)); }); edge.attr('d', datum => { const sourceX = datum.source.x, sourceY = datum.source.y; const targetX = datum.target.x, targetY = datum.target.y; const dx = targetX - sourceX, dy = targetY - sourceY; const length = Math.sqrt(dx * dx + dy * dy) || 1; // Shorten endpoints based on approach angle — text is wide but short const unitX = dx / length, unitY = dy / length; const absUX = Math.abs(unitX), absUY = Math.abs(unitY); const sourceHalfWidth = (datum.source.maxLineLength || 4) * collideRadius; const targetHalfWidth = (datum.target.maxLineLength || 4) * collideRadius; const sourceHalfHeight = 8 * (datum.source.lines?.length || 1); const targetHalfHeight = 8 * (datum.target.lines?.length || 1); const sourceMargin = absUX * sourceHalfWidth + absUY * sourceHalfHeight + 4; const targetMargin = absUX * targetHalfWidth + absUY * targetHalfHeight + 8; const x1 = sourceX + unitX * sourceMargin, y1 = sourceY + unitY * sourceMargin; const x2 = targetX - unitX * targetMargin, y2 = targetY - unitY * targetMargin; if (isBidirectional(datum)) { const perpX = -unitY * curveOffset, perpY = unitX * curveOffset; const controlX = (x1 + x2) / 2 + perpX, controlY = (y1 + y2) / 2 + perpY; return `M${x1},${y1} Q${controlX},${controlY} ${x2},${y2}`; } return `M${x1},${y1} L${x2},${y2}`; }); edgeLabel.each(function(datum) { const position = labelPosition(datum); d3.select(this).attr('x', position.x).attr('y', position.y); }); node.each(function(datum) { const element = d3.select(this); element.attr('x', datum.x).attr('y', datum.y); element.selectAll('tspan').attr('x', datum.x); }); } return { update() { simulation.alpha(0.3).restart(); }, settle() { simulation.tick(300); simulation.stop(); ticked(); } }; }, // 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 { settle } = internals.renderToSVG( svg, internals.parse(item.text), internals.kDimensions.full, ); settle(); const slug = $item.parents(".page").attr("id"); internals.download(`${slug}.svg`, svg.outerHTML); } 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 { settle } = internals.renderToSVG( svg, internals.parse(item.text), internals.kDimensions.full, ); settle(); const imageData = svg.outerHTML; // 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.label').on('click', function(event) { const title = this.dataset.title; 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;