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;