import { forceSimulation, forceLink, forceCenter, forceManyBody, forceCollide, } from "d3-force"; import * as d3 from "d3"; import DOMPurify from "isomorphic-dompurify"; //////////////////////////////////////////////////////////////////////////////// // Constants /////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// const styleSheet = "/plugins/causalloop/causalloop.css"; const theme = { positive: "#23C17C", negative: "#FA2B00", node: "#0F261F", halo: "#FFFFFF", }; const icons = { play: `data:image/svg+xml;charset=utf-8,`, pause: `data:image/svg+xml;charset=utf-8,`, step: `data:image/svg+xml;charset=utf-8,`, reset: `data:image/svg+xml;charset=utf-8,`, download: `data:image/svg+xml;charset=utf-8,`, zoom: `data:image/svg+xml;charset=utf-8,`, }; const dimensions = { full: { width: 806, height: 620, minSize: 11, initialSize: 14, maxSize: 22, collideRadius: 2, collideExtra: 25, haloWidth: 4, edgeLabelHaloWidth: 3, edgeWidth: 1.5, curveOffset: 20, linkDistance: 180, chargeStrength: -700, maxLabelWidth: 10, edgeFontSize: "11px", }, thumbnail: { width: 403, height: 310, minSize: 9, initialSize: 11, maxSize: 17, collideRadius: 2, collideExtra: 10, haloWidth: 3, edgeLabelHaloWidth: 2, edgeWidth: 1, curveOffset: 12, linkDistance: 80, chargeStrength: -300, maxLabelWidth: 10, edgeFontSize: "10px", }, // Animation config. Only the thumbnail animates — the zoom/download // path uses settle() to pre-render a static SVG and never fires dots. animation: { growthFactor: 0.1, flowInterval: 1500, flowDuration: 1200, dotRadius: 3, }, }; const lineRegex = /([^\s].*?)([+-])>\s*([^\s].*?)(?:\[((?:\d+(?:\.\d+)?)|(?:\.\d+))\])?$/; //////////////////////////////////////////////////////////////////////////////// // Parsing ///////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Walks the lineup of items preceding $item and collects parsed diagrams // from every .causalloop-source it finds. Returns [] outside the wiki // (no lineup to walk). function lineup($item) { if (typeof wiki === "undefined") return []; const results = []; const candidates = $(".item:lt(" + $(".item").index($item) + ")"); const sources = candidates.filter(".causalloop-source"); for (const source of sources) { results.push(source.causalloopData()); } return results; } // Parses the causal loop diagram line by line and generates an object // that looks like this: // - nodes: Set // - connections: Object> // - state: Object // // A bare "LINEUP" line pulls in nodes and connections from every // .causalloop-source item earlier in the lineup. Local connections win // over upstream ones with the same (source, target) key. function parse(text, $item) { const nodes = new Set(); const connections = {}; const seenConnections = new Set(); for (const rawLine of text.split("\n")) { if (rawLine.trim() === "LINEUP") { for (const upstream of lineup($item)) { for (const id of upstream.nodes) nodes.add(id); for (const [source, outgoing] of Object.entries( upstream.connections, )) { for (const conn of outgoing) { const key = `${source}\0${conn.target}`; if (seenConnections.has(key)) continue; seenConnections.add(key); (connections[source] ??= []).push({ ...conn }); } } } continue; } const matches = rawLine.match(lineRegex); if (matches === null) continue; const source = matches[1]?.trim(); const target = matches[3]?.trim(); const direction = matches[2]?.trim(); if (!source || !target) continue; const connectionKey = `${source}\0${target}`; if (seenConnections.has(connectionKey)) continue; seenConnections.add(connectionKey); let weight = parseFloat(matches[4]) || 1; if (direction === "-") weight = -weight; nodes.add(source); nodes.add(target); (connections[source] ??= []).push({ weight, target }); } const state = Object.fromEntries([...nodes].map((id) => [id, 1])); return { nodes, connections, state }; } //////////////////////////////////////////////////////////////////////////////// // Render Helpers ////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Wraps a label string into multiple lines so no line exceeds // maxLabelWidth characters. function wrapLabel(text, maxLabelWidth) { if (text.length <= maxLabelWidth) return [text]; const words = text.split(/\s+/); const lines = []; let currentLine = words[0]; for (let index = 1; index < words.length; index++) { if (currentLine.length + 1 + words[index].length <= maxLabelWidth) { currentLine += " " + words[index]; } else { lines.push(currentLine); currentLine = words[index]; } } lines.push(currentLine); return lines; } // Builds the d3 graph data (nodes, links, and a bidirectional predicate) // from a parsed diagram. function buildGraphData(parsedDiagram, maxLabelWidth) { const graphNodes = [...parsedDiagram.nodes].map((id) => { const lines = wrapLabel(id, maxLabelWidth); const maxLineLength = Math.max(...lines.map((line) => line.length)); return { id, lines, maxLineLength }; }); const graphLinks = Object.entries(parsedDiagram.connections).flatMap( ([source, outgoing]) => outgoing.map(({ target, weight }) => ({ source, target, weight })), ); const linkKeys = new Set( graphLinks.map((link) => `${link.source}\0${link.target}`), ); for (const link of graphLinks) { link.bidirectional = linkKeys.has(`${link.target}\0${link.source}`); } return { graphNodes, graphLinks }; } // Appends the positive/negative arrow markers to the SVG's . function drawMarkers(svg) { svg.append("defs") .selectAll("marker") .data([ { id: "arrow-positive", color: theme.positive }, { id: "arrow-negative", color: theme.negative }, ]) .join("marker") .attr("id", (marker) => marker.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", (marker) => marker.color); } // Draws the edges group and returns the path selection. function drawEdges(svg, graphLinks, { edgeWidth }) { return svg .append("g") .attr("fill", "none") .selectAll("path") .data(graphLinks) .join("path") .attr("stroke", (link) => link.weight > 0 ? theme.positive : theme.negative, ) .attr("stroke-width", edgeWidth) .attr("marker-end", (link) => link.weight > 0 ? "url(#arrow-positive)" : "url(#arrow-negative)", ); } // Draws the edge-label group and returns the text selection. function drawEdgeLabels(svg, graphLinks, { edgeFontSize, edgeLabelHaloWidth }) { return svg .append("g") .attr("font-family", "serif") .attr("font-size", edgeFontSize) .selectAll("text") .data(graphLinks) .join("text") .attr("text-anchor", "middle") .attr("dy", "-4") .attr("fill", (link) => link.weight > 0 ? theme.positive : theme.negative, ) .attr("stroke", theme.halo) .attr("stroke-width", edgeLabelHaloWidth) .attr("paint-order", "stroke") .text((link) => (link.weight > 0 ? "S(+)" : "O(-)")); } // Draws the node-label group (with wrapped tspans) and returns the text // selection. sizeFor is a callback because font-size tracks live state. function drawNodes(svg, graphNodes, sizeFor, { haloWidth }) { const lineHeight = 1.2; return svg .append("g") .attr("font-family", "serif") .selectAll("text") .data(graphNodes) .join("text") .attr("text-anchor", "middle") .attr("fill", theme.node) .attr("stroke", theme.halo) .attr("stroke-width", haloWidth) .attr("paint-order", "stroke") .attr("font-size", (graphNode) => `${sizeFor(graphNode)}px`) .attr("class", "label") .attr("data-title", (graphNode) => graphNode.id) .each(function (graphNode) { const element = d3.select(this); const verticalOffset = 0.35 - ((graphNode.lines.length - 1) * 1.2) / 2; graphNode.lines.forEach((line, index) => { element .append("tspan") .attr( "dy", index === 0 ? `${verticalOffset}em` : `${lineHeight}em`, ) .text(line); }); }); } // Computes the SVG path string for a causal link, inset by each node's // halo and curved outward for bidirectional links. function computeEdgePath(link, scaleFactor, collideRadius, curveOffset) { const { source, target } = link; const deltaX = target.x - source.x; const deltaY = target.y - source.y; const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; const unitX = deltaX / length; const unitY = deltaY / length; const absUnitX = Math.abs(unitX); const absUnitY = Math.abs(unitY); const halfW = (node) => node.maxLineLength * collideRadius * scaleFactor(node); const halfH = (node) => 8 * node.lines.length * scaleFactor(node); const sourceMargin = absUnitX * halfW(source) + absUnitY * halfH(source) + 4; const targetMargin = absUnitX * halfW(target) + absUnitY * halfH(target) + 8; const startX = source.x + unitX * sourceMargin; const startY = source.y + unitY * sourceMargin; const endX = target.x - unitX * targetMargin; const endY = target.y - unitY * targetMargin; if (!link.bidirectional) { return `M${startX},${startY} L${endX},${endY}`; } const perpendicularX = -unitY * curveOffset; const perpendicularY = unitX * curveOffset; const controlX = (startX + endX) / 2 + perpendicularX; const controlY = (startY + endY) / 2 + perpendicularY; return `M${startX},${startY} Q${controlX},${controlY} ${endX},${endY}`; } // Returns the xy position for an edge label, offset outward for // bidirectional links so labels don't overlap the curves. function labelPosition(link, curveOffset) { const midpointX = (link.source.x + link.target.x) / 2; const midpointY = (link.source.y + link.target.y) / 2; if (!link.bidirectional) return { x: midpointX, y: midpointY }; const deltaX = link.target.x - link.source.x; const deltaY = link.target.y - link.source.y; const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; return { x: midpointX + -(deltaY / length) * curveOffset, y: midpointY + (deltaX / length) * curveOffset, }; } //////////////////////////////////////////////////////////////////////////////// // Sourcing //////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Finds the name for this diagram by walking the DOM to the nearest // preceding heading on the same page. When multiple .causalloop blocks // share that heading, suffixes " #2", " #3", … in document order. function findName($element) { const page = $element.closest(".page"); if (!page) return "Unknown"; const headings = page.querySelectorAll("h1, h2, h3, h4, h5, h6"); let closestHeading = null; for (const heading of headings) { const elementFollowsHeading = heading.compareDocumentPosition($element) & Node.DOCUMENT_POSITION_FOLLOWING; if (elementFollowsHeading) closestHeading = heading; } if (!closestHeading) return "Unknown"; const baseName = closestHeading.textContent.trim(); const causalloops = page.querySelectorAll(".causalloop.item"); let position = 0; for (const causalloop of causalloops) { const causalloopFollowsHeading = closestHeading.compareDocumentPosition(causalloop) & Node.DOCUMENT_POSITION_FOLLOWING; if (!causalloopFollowsHeading) continue; position++; if (causalloop === $element) break; } return position <= 1 ? baseName : `${baseName} #${position}`; } // Creates the aspect data type out of the existing text. // tAspectData := { // +name: String, // +graph: tAspectGraph // } // tAspectGraph := { // nodes: Array, // rels: Array // } // tAspectNode := { // type: String, // in: Array, // out: Array, // props: Object // } // tAspectRelation := { // type: String, // from: Number, // to: Number, // props: Object // } function aspectData($element, text) { const $item = $($element); const { nodes, connections } = parse(text, $item); const nodeIds = [...nodes]; const indexById = new Map(nodeIds.map((id, index) => [id, index])); const aspectNodes = nodeIds.map((id) => ({ type: "variable", in: [], out: [], props: { name: id }, })); const rels = []; for (const [source, outgoing] of Object.entries(connections)) { for (const { weight, target } of outgoing) { const from = indexById.get(source); const to = indexById.get(target); const relIndex = rels.length; rels.push({ type: weight > 0 ? "reinforces" : "balances", from, to, props: { magnitude: Math.abs(weight) }, }); aspectNodes[from].out.push(relIndex); aspectNodes[to].in.push(relIndex); } } return [{ name: findName($element), graph: { nodes: aspectNodes, rels }, }]; } // Returns this diagram's nodes + connections for upstream consumers. // LINEUP inside item.text expands recursively via parse(). function causalloopData(item, $item) { return parse(item.text, $item); } //////////////////////////////////////////////////////////////////////////////// // Animation /////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Builds the cascading flow animator that fires dots along edges and // updates node state. Owns the play/pause/step/reset lifecycle. Created // once per renderToSVG call; only the thumbnail path ever calls play(). function createFlowAnimator({ parsedDiagram, graphLinks, dotGroup, animation, minStateFactor, maxStateFactor, onSizeChange, }) { const { dotRadius, flowDuration, flowInterval, growthFactor } = animation; const outgoingByNode = new Map(); for (const link of graphLinks) { const sourceId = link.source.id ?? link.source; const list = outgoingByNode.get(sourceId) ?? []; list.push(link); outgoingByNode.set(sourceId, list); } let flowTimer = null; let firingNodes = new Map(); const applyDelta = (id, delta) => { const next = parsedDiagram.state[id] * (1 + delta); parsedDiagram.state[id] = Math.max( minStateFactor, Math.min(maxStateFactor, next), ); }; const animateDot = (link, delta) => { const pathElement = link.pathElement; if (!pathElement) return; const length = pathElement.getTotalLength(); if (!length) return; const targetId = link.target.id ?? link.target; const dot = dotGroup .append("circle") .attr("r", dotRadius) .attr("fill", delta >= 0 ? theme.positive : theme.negative) .attr("stroke", theme.halo) .attr("stroke-width", 1); dot.transition() .duration(flowDuration) .ease(d3.easeLinear) .attrTween("transform", () => (progress) => { const point = pathElement.getPointAtLength(progress * length); return `translate(${point.x},${point.y})`; }) .on("end", () => { dot.remove(); applyDelta(targetId, delta); onSizeChange(); }); }; const tick = () => { if (firingNodes.size === 0) { // re-seed from the first node when the cascade dies out const seedNodeId = parsedDiagram.nodes.values().next().value; if (!seedNodeId) return; applyDelta(seedNodeId, growthFactor); firingNodes = new Map([[seedNodeId, growthFactor]]); onSizeChange(); } const nextFiring = new Map(); for (const [sourceId, sourceDelta] of firingNodes) { const outgoing = outgoingByNode.get(sourceId) ?? []; for (const link of outgoing) { const targetDelta = sourceDelta * link.weight; if (!targetDelta) continue; animateDot(link, targetDelta); const targetId = link.target.id ?? link.target; nextFiring.set( targetId, (nextFiring.get(targetId) ?? 0) + targetDelta, ); } } firingNodes = nextFiring; }; const pause = () => { if (flowTimer) { flowTimer.stop(); flowTimer = null; } dotGroup.selectAll("*").interrupt().remove(); firingNodes = new Map(); }; const reset = () => { pause(); for (const id of parsedDiagram.nodes) { parsedDiagram.state[id] = 1; } onSizeChange(); }; return { play() { if (flowTimer) return; tick(); flowTimer = d3.interval(tick, flowInterval); }, pause, step: tick, reset, isPlaying: () => flowTimer !== null, isDirty: () => [...parsedDiagram.nodes].some( (id) => parsedDiagram.state[id] !== 1, ), }; } //////////////////////////////////////////////////////////////////////////////// // Main Renderer /////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Given an existing SVG element, a parsed diagram (see #parse()), and a // layout dimensions object, sets up the d3 state to draw in the SVG and // returns functions to control its state or pre-render the object. function renderToSVG($element, parsedDiagram, layout, options = {}) { const { width, height, minSize, initialSize, maxSize, collideRadius, collideExtra, curveOffset, linkDistance, chargeStrength, maxLabelWidth, } = layout; const { animation } = dimensions; const minStateFactor = minSize / initialSize; const maxStateFactor = maxSize / initialSize; const sizeFor = (graphNode) => Math.max( minSize, Math.min(maxSize, initialSize * parsedDiagram.state[graphNode.id]), ); const scaleFactor = (graphNode) => sizeFor(graphNode) / initialSize; const radiusFor = (graphNode) => graphNode.maxLineLength * collideRadius * scaleFactor(graphNode) + collideExtra; const { graphNodes, graphLinks } = buildGraphData( parsedDiagram, maxLabelWidth, ); const svg = d3.select($element).attr("viewBox", [0, 0, width, height]); svg.selectAll("*").remove(); drawMarkers(svg); const edge = drawEdges(svg, graphLinks, layout); const edgeLabel = drawEdgeLabels(svg, graphLinks, layout); const node = drawNodes(svg, graphNodes, sizeFor, layout); const dotGroup = svg.append("g").attr("class", "flow-dots"); edge.each(function (link) { link.pathElement = this; }); const ticked = () => { const padding = 40; graphNodes.forEach((graphNode) => { graphNode.x = Math.max( padding, Math.min(width - padding, graphNode.x), ); graphNode.y = Math.max( padding, Math.min(height - padding, graphNode.y), ); }); edge.attr("d", (link) => computeEdgePath(link, scaleFactor, collideRadius, curveOffset), ); edgeLabel .attr("x", (link) => labelPosition(link, curveOffset).x) .attr("y", (link) => labelPosition(link, curveOffset).y); node.attr("x", (graphNode) => graphNode.x) .attr("y", (graphNode) => graphNode.y) .each(function (graphNode) { d3.select(this).selectAll("tspan").attr("x", graphNode.x); }); }; const simulation = forceSimulation(graphNodes) .force( "link", forceLink(graphLinks) .id((graphNode) => graphNode.id) .distance(linkDistance), ) .force("charge", forceManyBody().strength(chargeStrength)) .force("center", forceCenter(width / 2, height / 2)) .force("collide", forceCollide().radius(radiusFor)) .on("tick", ticked); const updateSizes = () => { node.attr("font-size", (graphNode) => `${sizeFor(graphNode)}px`); simulation.force("collide").radius(radiusFor); simulation.alpha(0.2).restart(); options.onChange?.(); }; const animator = createFlowAnimator({ parsedDiagram, graphLinks, dotGroup, animation, minStateFactor, maxStateFactor, onSizeChange: updateSizes, }); return { update() { simulation.alpha(0.3).restart(); }, settle() { simulation.tick(300); simulation.stop(); ticked(); }, ...animator, }; } //////////////////////////////////////////////////////////////////////////////// // SVG Export ////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Creates an SVG element with an explicit xmlns attribute so outerHTML // serializes cleanly for download/zoom. function createSVG() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); return svg; } // Creates a pre-rendered SVG by settling the animation. function renderStandaloneSVG($item, item) { const svg = createSVG(); const { settle } = renderToSVG( svg, parse(item.text, $item), dimensions.full, ); settle(); return svg; } // Starts a download of an SVG text blob with the given filename. function download(filename, text) { const blob = new Blob([text], { 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 dialog with the new image data. function replaceImage(dialogWindow, imageData) { try { const $container = dialogWindow.document.querySelector("main"); $container.innerHTML = imageData; } catch (error) { console.error( "causalloop: Could not replace image. The DOM wasn't ready!", error, ); } } //////////////////////////////////////////////////////////////////////////////// // UI Helpers ////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Returns HTML that shows a message in the wiki. function message(text) { return `
${text}
`; } // HTML for the thumbnail container and its action bar. Built once at // module load so the icons are interpolated once. const thumbnailHTML = `
`; //////////////////////////////////////////////////////////////////////////////// // Event Handlers ////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// // Runs a callback when the dialog is loaded. // readyState is "complete" on the initial empty window, so we must // also verify the dialog's href to know the document is really ours. function onDialogReady(dialog, callback) { const isReady = dialog.document.readyState === "complete" && dialog.document.location.href.includes("/plugins/causalloop/dialog/"); if (isReady) { callback(); } else { dialog.addEventListener("load", callback, { once: true }); } } // Triggered when the download button is clicked. function onDownload($item, item) { const svg = renderStandaloneSVG($item, item); const slug = $item.parents(".page").attr("id"); download(`${slug}.svg`, svg.outerHTML); } // Triggered when the zoom button is clicked. // It opens the dialog and loads the image. function onZoom(event, $item, item) { const target = event.shiftKey ? "_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 imageData = renderStandaloneSVG($item, item).outerHTML; onDialogReady(dialog, () => replaceImage(dialog, imageData)); } //////////////////////////////////////////////////////////////////////////////// // Plugin Lifecycle //////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// /** * Runs once for each block. Do setup for the block before rendering. */ const emit = function emit($item) { const hasStyleSheet = [...document.styleSheets].some((sheet) => sheet.href?.endsWith(styleSheet), ); if (!hasStyleSheet) { console.log("Adding causalloop stylesheet."); const link = document.createElement("link"); link.rel = "stylesheet"; link.href = styleSheet; link.type = "text/css"; document.getElementsByTagName("head")[0].appendChild(link); } return $item.append(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) { let renderer = null; $item.on("dblclick", () => wiki.textEditor($item, item)); // Single source of truth for the action bar DOM. Reads live state off // the renderer and drives the play/pause icon and step/reset visibility. const syncActions = () => { if (!renderer) return; const playing = renderer.isPlaying(); const dirty = renderer.isDirty(); $item .find('a[data-action="play"] img') .attr("src", playing ? icons.pause : icons.play); $item .find('a[data-action="step"]') .parent() .css("display", playing ? "none" : ""); $item .find('a[data-action="reset"]') .parent() .css("display", dirty ? "" : "none"); }; $item.on("click", "a", (event) => { const action = event.currentTarget.dataset?.action; if (!action) return; event.stopPropagation(); event.preventDefault(); switch (action) { case "download": onDownload($item, item); return; case "zoom": onZoom(event, $item, item); return; case "play": if (renderer?.isPlaying()) renderer.pause(); else renderer?.play(); break; case "step": renderer?.step(); break; case "reset": renderer?.reset(); break; } syncActions(); }); try { // Register the source hooks before rendering so any sibling // binding concurrently can resolve this item via lineup(). const root = $item.get(0); root.classList.add('aspect-source'); root.classList.add('causalloop-source'); root.aspectData = () => aspectData(root, item.text); root.causalloopData = () => causalloopData(item, $item); const $element = createSVG(); renderer = renderToSVG( $element, parse(item.text, $item), dimensions.thumbnail, { onChange: syncActions }, ); $item.find(".viewer").html(thumbnailHTML); $item.find(".causalloop").append($element); syncActions(); $item.find("svg text.label").on("click", function (event) { const title = this.dataset.title; const $page = $item.parents(".page"); wiki.pageHandler.context = wiki.lineup .atKey($page.data("key")) .getContext(); wiki.doInternalLink(title, event.shiftKey ? null : $page); }); } catch (error) { console.log("Failed to parse causal loop diagram: ", error); $item.html(message(error.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;