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;