import { forceSimulation, forceLink, forceCenter, forceManyBody, forceCollide, } from "d3-force"; import * as d3 from "d3"; import DOMPurify from "isomorphic-dompurify"; const internals = { styleSheet: "/plugins/causalloop/causalloop.css", theme: { positive: "#23C17C", negative: "#FA2B00", node: "#0F261F", halo: "#FFFFFF", }, 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,`, }, dimensions: { full: { width: 806, height: 620, minSize: 11, initialSize: 14, maxSize: 22, growthFactor: 0.2, collideRadius: 2, collideExtra: 25, haloWidth: 4, edgeLabelHaloWidth: 3, edgeWidth: 1.5, curveOffset: 20, linkDistance: 180, chargeStrength: -700, maxLabelWidth: 10, edgeFontSize: "11px", flowInterval: 1500, flowDuration: 1200, dotRadius: 4, }, thumbnail: { width: 403, height: 310, minSize: 9, initialSize: 11, maxSize: 17, growthFactor: 0.1, collideRadius: 2, collideExtra: 10, haloWidth: 3, edgeLabelHaloWidth: 2, edgeWidth: 1, curveOffset: 12, linkDistance: 80, chargeStrength: -300, maxLabelWidth: 10, edgeFontSize: "10px", flowInterval: 1500, flowDuration: 1200, dotRadius: 3, }, }, lineRegex: /([^\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 = {}; const seenConnections = new Set(); for (const rawLine of text.split("\n")) { const matches = rawLine.match(internals.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 }; }, /** * Wraps a label string into multiple lines so no line exceeds * maxLabelWidth characters. */ 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. */ buildGraphData(parsedDiagram, maxLabelWidth) { const graphNodes = [...parsedDiagram.nodes].map((id) => { const lines = internals.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}`), ); const isBidirectional = (link) => linkKeys.has(`${link.target.id}\0${link.source.id}`); return { graphNodes, graphLinks, isBidirectional }; }, /** * Appends the positive/negative arrow markers to the SVG's . */ drawMarkers(svg, theme) { 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. */ drawEdges(svg, graphLinks, theme, 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. */ drawEdgeLabels(svg, graphLinks, theme, 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. */ drawNodes(svg, graphNodes, theme, 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. */ computeEdgePath( link, isBidirectional, scaleFactor, collideRadius, curveOffset, ) { const nodeMargin = ( graphNode, absoluteUnitX, absoluteUnitY, extraPadding, ) => { const factor = scaleFactor(graphNode); const halfWidth = graphNode.maxLineLength * collideRadius * factor; const halfHeight = 8 * graphNode.lines.length * factor; return ( absoluteUnitX * halfWidth + absoluteUnitY * halfHeight + extraPadding ); }; 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 absoluteUnitX = Math.abs(unitX); const absoluteUnitY = Math.abs(unitY); const sourceMargin = nodeMargin( source, absoluteUnitX, absoluteUnitY, 4, ); const targetMargin = nodeMargin( target, absoluteUnitX, absoluteUnitY, 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 (!isBidirectional(link)) { 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. */ labelPosition(link, isBidirectional, curveOffset) { const midpointX = (link.source.x + link.target.x) / 2; const midpointY = (link.source.y + link.target.y) / 2; if (!isBidirectional(link)) 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, }; }, /** * Given an existing SVG element, a parsed diagram (see #parse()), and a * dimensions object, sets up the d3 state to draw in the SVG and returns * functions to control its state or pre-render the object. */ renderToSVG($element, parsedDiagram, dimensions, options = {}) { const { width, height, minSize, initialSize, maxSize, growthFactor, collideRadius, collideExtra, haloWidth, edgeLabelHaloWidth, edgeWidth, curveOffset, linkDistance, chargeStrength, maxLabelWidth, edgeFontSize, flowInterval, flowDuration, dotRadius, } = dimensions; const { theme } = internals; 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, isBidirectional } = internals.buildGraphData(parsedDiagram, maxLabelWidth); const svg = d3.select($element).attr("viewBox", [0, 0, width, height]); svg.selectAll("*").remove(); internals.drawMarkers(svg, theme); const edge = internals.drawEdges(svg, graphLinks, theme, edgeWidth); const edgeLabel = internals.drawEdgeLabels( svg, graphLinks, theme, edgeFontSize, edgeLabelHaloWidth, ); const node = internals.drawNodes( svg, graphNodes, theme, sizeFor, haloWidth, ); const dotGroup = svg.append("g").attr("class", "flow-dots"); const pathByKey = new Map(); edge.each(function (link) { pathByKey.set(`${link.source}\0${link.target}`, this); }); 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); let flowTimer = null; let firingNodes = null; function updateSizes() { node.attr("font-size", (graphNode) => `${sizeFor(graphNode)}px`); simulation.force("collide").radius(radiusFor); simulation.alpha(0.2).restart(); options.onChange?.(); } function isDirty() { return [...parsedDiagram.nodes].some( (id) => parsedDiagram.state[id] !== 1, ); } function applyDelta(id, delta) { const next = parsedDiagram.state[id] * (1 + delta); parsedDiagram.state[id] = Math.max( minStateFactor, Math.min(maxStateFactor, next), ); } function animateDot(sourceId, targetId, delta) { const pathElement = pathByKey.get(`${sourceId}\0${targetId}`); if (!pathElement) return; const length = pathElement.getTotalLength(); if (!length) return; 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); updateSizes(); }); } function tickFlow() { if (!firingNodes || 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]]); updateSizes(); } const nextFiring = new Map(); for (const [sourceId, sourceDelta] of firingNodes) { const outgoing = parsedDiagram.connections[sourceId] || []; for (const { target: targetId, weight } of outgoing) { const targetDelta = sourceDelta * weight; if (!targetDelta) continue; animateDot(sourceId, targetId, targetDelta); nextFiring.set( targetId, (nextFiring.get(targetId) ?? 0) + targetDelta, ); } } firingNodes = nextFiring; } function 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) => internals.computeEdgePath( link, isBidirectional, scaleFactor, collideRadius, curveOffset, ), ); edgeLabel .attr( "x", (link) => internals.labelPosition( link, isBidirectional, curveOffset, ).x, ) .attr( "y", (link) => internals.labelPosition( link, isBidirectional, 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); }); } function pause() { if (flowTimer) { flowTimer.stop(); flowTimer = null; } dotGroup.selectAll("*").interrupt().remove(); firingNodes = null; } function reset() { pause(); for (const id of parsedDiagram.nodes) { parsedDiagram.state[id] = 1; } updateSizes(); } return { update() { simulation.alpha(0.3).restart(); }, settle() { simulation.tick(300); simulation.stop(); ticked(); }, play() { if (flowTimer) return; tickFlow(); flowTimer = d3.interval(tickFlow, flowInterval); }, pause, reset, step() { tickFlow(); }, isPlaying() { return flowTimer !== null; }, isDirty, }; }, // Returns HTML that shows a message in the wiki. message(text) { return `
${text}
`; }, /** * Wraps the SVG with the prelude and xml version and starts a download. */ download(filename, text) { const svgWithNamespace = text.replace( " internals.replaceImage(dialog, imageData), ); }, // Triggered when the play button is clicked. // It toggles the available icons and starts the animation. onPlay(event, renderer, $item) { if (!renderer) return; const $icon = $(event.currentTarget).find("img"); const $stepButton = $item.find('a[data-action="step"]').parent(); if (renderer.isPlaying()) { renderer.pause(); $icon.attr("src", internals.icons.play); $stepButton.css("display", ""); } else { renderer.play(); $icon.attr("src", internals.icons.pause); $stepButton.css("display", "none"); } }, // Triggered when the step button is clicked. // Moves the animation one iteration. onStep(renderer) { renderer?.step(); }, // Triggered when the reset button is clicked. // Resets the animation. onReset(renderer, $item) { if (!renderer) return; renderer.reset(); $item .find('a[data-action="play"] img') .attr("src", internals.icons.play); }, }; /** * 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.styleSheet), ); if (!hasStyleSheet) { console.log("Adding causalloop stylesheet."); const link = document.createElement("link"); link.rel = "stylesheet"; link.href = internals.styleSheet; 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) { let renderer = null; $item.on("dblclick", () => wiki.textEditor($item, item)); $item.on("click", "a", async (event) => { const action = event.currentTarget.dataset?.action; if (!action) return; event.stopPropagation(); event.preventDefault(); switch (action) { case "download": internals.onDownload($item, item); break; case "zoom": internals.onZoom(event, item); break; case "play": internals.onPlay(event, renderer, $item); break; case "step": internals.onStep(renderer); break; case "reset": internals.onReset(renderer, $item); break; } }); try { const $element = internals.createSVG(); renderer = internals.renderToSVG( $element, internals.parse(item.text), internals.dimensions.thumbnail, { onChange() { const dirty = renderer?.isDirty() ?? false; $item .find('a[data-action="reset"]') .parent() .css("display", dirty ? "" : "none"); }, }, ); $item.find(".viewer").html(`
`); $item.find(".causalloop").append($element); $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(internals.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;