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 `
`;
},
/**
* 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;