aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-04-14 19:54:02 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-04-14 22:55:17 +0200
commitc4d6449f3496853519146d6b63058e000092658d (patch)
treeddbe4238f4a66bdead51c1ed75edde0c8ac891af
parent37fa67b9ad1f8a6183fa4a8308c01ec5449b4730 (diff)
Add animation / stepping of model
-rw-r--r--README.md1
-rw-r--r--client/causalloop.css3
-rw-r--r--pages/about-causal-loop-plugin369
-rw-r--r--src/causalloop.js858
4 files changed, 953 insertions, 278 deletions
diff --git a/README.md b/README.md
index b93a605..15b8ab9 100644
--- a/README.md
+++ b/README.md
@@ -49,4 +49,3 @@ so it can load the latest version of your code.
- Run tests with `make test`
- Format the code with `make format`
- Lint the code with `make lint`
-- Generate typescript types with `make types`
diff --git a/client/causalloop.css b/client/causalloop.css
index 78875d1..175cbf9 100644
--- a/client/causalloop.css
+++ b/client/causalloop.css
@@ -27,5 +27,6 @@
}
.causalloop.thumbnail text.label {
- cursor: pointer;
+ cursor: pointer;
+ transition: 1s font-size;
}
diff --git a/pages/about-causal-loop-plugin b/pages/about-causal-loop-plugin
index 360825f..dcad8a4 100644
--- a/pages/about-causal-loop-plugin
+++ b/pages/about-causal-loop-plugin
@@ -1,15 +1,378 @@
+
+
{
- "title": "About Causal Loop Plugin",
+ "title": "About CausalLoop Plugin",
"story": [
+ {
+ "type": "paragraph",
+ "id": "456121e3050930aa",
+ "text": ""
+ },
+ {
+ "type": "paragraph",
+ "id": "9a406c4ac09a2f84",
+ "text": "This plugin renders causal loop diagrams by parsing a simple notation:"
+ },
+ {
+ "type": "paragraph",
+ "id": "d6809bf7c999ad1b",
+ "text": "A +> B creates a positive / same direction link from A to B."
+ },
+ {
+ "type": "paragraph",
+ "id": "0f52891a9e417c8a",
+ "text": "A -> B creates a negative / opposite direction link from A to B."
+ },
+ {
+ "type": "paragraph",
+ "id": "d4ae6b062702051e",
+ "text": "You can append [n] where n is any positive whole or decimal number to modify the power of that link. This is used when animating the model. Links have a value of 1 by default."
+ },
+ {
+ "type": "paragraph",
+ "id": "e3e4199eb564d0af",
+ "text": "For example: \"Room Temperature +> Body Temperature [0.5]\" creates a link from Room Temperature to Body Temperature with a value of 0.5"
+ },
+ {
+ "type": "causalloop",
+ "id": "99b6c1f181f06109",
+ "text": "Room Temperature -> Heating\nHeating +> Room Temperature\nRoom Temperature +> Cooling\nCooling -> Room Temperature\nExternal Temperature +> Room Temperature\n\nRoom Temperature +> Body Temperature [0.5]\n\nBody Temperature +> Sweating\nSweating -> Body Temperature\nBody Temperature -> Movement\nMovement +> Body Temperature\nBody Temperature -> Clothes [0.2]\nClothes +> Body Temperature\nBody Temperature -> Shivering\nShivering +> Body Temperature [0.2]"
+ },
+ {
+ "type": "paragraph",
+ "id": "3ea052a063f9b7d4",
+ "text": "A rendered diagram will have the following buttons:"
+ },
+ {
+ "type": "markdown",
+ "id": "22af2e69ff73e300",
+ "text": "* Four arrows pointing outward represent \"Zoom\", and will open a pop-up dialog with a larger and static version of the diagram.\n* An arrow pointing down with a line represents \"Download\", and will download a static SVG of the diagram.\n* A triangle pointing to the right represents \"Play\" and will start an animation showing how the diagram is influenced as properties change.\n* A triangle pointing to the right with a vertical line represents \"Step\", which will trigger a single step of the animation.\n* A circular arrow represents \"Reset\", and will reset the diagram to its original state.\n* Two thick vertical bars represent \"Pause\", and will pause the animation."
+ }
],
"journal": [
{
+ "type": "fork",
+ "site": "localhost:3000",
+ "date": 1776196620304
+ },
+ {
"type": "create",
"item": {
- "title": "About Causal Loop Plugin",
+ "title": "About CausalLoop Plugin",
"story": []
},
- "date": 1766350828505
+ "site": "localhost:3000",
+ "date": 1776196620305,
+ "fork": "localhost:3000"
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "ba7527590141d706"
+ },
+ "id": "ba7527590141d706",
+ "type": "add",
+ "date": 1776196621871
+ },
+ {
+ "type": "remove",
+ "id": "ba7527590141d706",
+ "date": 1776196624038
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "456121e3050930aa"
+ },
+ "id": "456121e3050930aa",
+ "type": "add",
+ "date": 1776196626438
+ },
+ {
+ "type": "edit",
+ "id": "456121e3050930aa",
+ "item": {
+ "type": "paragraph",
+ "id": "456121e3050930aa",
+ "text": ""
+ },
+ "date": 1776196629655
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "9a406c4ac09a2f84"
+ },
+ "id": "9a406c4ac09a2f84",
+ "type": "add",
+ "after": "456121e3050930aa",
+ "date": 1776196635155
+ },
+ {
+ "type": "edit",
+ "id": "9a406c4ac09a2f84",
+ "item": {
+ "type": "paragraph",
+ "id": "9a406c4ac09a2f84",
+ "text": "This plugin renders causal loop diagrams by parsing a simple notation:"
+ },
+ "date": 1776196663356
+ },
+ {
+ "type": "add",
+ "id": "d6809bf7c999ad1b",
+ "item": {
+ "type": "paragraph",
+ "id": "d6809bf7c999ad1b",
+ "text": "A +> B creates a positive / same direction link from A to B."
+ },
+ "after": "9a406c4ac09a2f84",
+ "date": 1776196703673
+ },
+ {
+ "type": "add",
+ "id": "0f52891a9e417c8a",
+ "item": {
+ "type": "paragraph",
+ "id": "0f52891a9e417c8a",
+ "text": "A -> B creates a negative / opposite direction link from A to B."
+ },
+ "after": "d6809bf7c999ad1b",
+ "date": 1776196713740
+ },
+ {
+ "type": "add",
+ "id": "d4ae6b062702051e",
+ "item": {
+ "type": "paragraph",
+ "id": "d4ae6b062702051e",
+ "text": "You can append [n] where n is any positive whole or decimal number to modify the power of that link. This is used when animating the model."
+ },
+ "after": "0f52891a9e417c8a",
+ "date": 1776196752607
+ },
+ {
+ "type": "add",
+ "id": "e3e4199eb564d0af",
+ "item": {
+ "type": "paragraph",
+ "id": "e3e4199eb564d0af",
+ "text": "For example:"
+ },
+ "after": "d4ae6b062702051e",
+ "date": 1776196757124
+ },
+ {
+ "type": "edit",
+ "id": "d4ae6b062702051e",
+ "item": {
+ "type": "paragraph",
+ "id": "d4ae6b062702051e",
+ "text": "You can append [n] where n is any positive whole or decimal number to modify the power of that link. This is used when animating the model. Links have a value of 1 by default."
+ },
+ "date": 1776196767091
+ },
+ {
+ "type": "edit",
+ "id": "e3e4199eb564d0af",
+ "item": {
+ "type": "paragraph",
+ "id": "e3e4199eb564d0af",
+ "text": "For example: \"Room Temperature +> Body Temperature [0.5]\" creates a link from Room Temperature to Body Temperature with a value of 0.5"
+ },
+ "date": 1776196807025
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "930046040d492680"
+ },
+ "id": "930046040d492680",
+ "type": "add",
+ "after": "e3e4199eb564d0af",
+ "date": 1776196811359
+ },
+ {
+ "type": "remove",
+ "id": "930046040d492680",
+ "date": 1776196817642
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "99b6c1f181f06109"
+ },
+ "id": "99b6c1f181f06109",
+ "type": "add",
+ "after": "e3e4199eb564d0af",
+ "date": 1776196823659
+ },
+ {
+ "type": "edit",
+ "id": "99b6c1f181f06109",
+ "item": {
+ "type": "causalloop",
+ "id": "99b6c1f181f06109",
+ "text": "Room Temperature -> Heating\nHeating +> Room Temperature\nRoom Temperature +> Cooling\nCooling -> Room Temperature\nExternal Temperature +> Room Temperature\n\nRoom Temperature +> Body Temperature [0.5]\n\nBody Temperature +> Sweating\nSweating -> Body Temperature\nBody Temperature -> Movement\nMovement +> Body Temperature\nBody Temperature -> Clothes [0.2]\nClothes +> Body Temperature\nBody Temperature -> Shivering\nShivering +> Body Temperature [0.2]"
+ },
+ "date": 1776196847559
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "3ea052a063f9b7d4"
+ },
+ "id": "3ea052a063f9b7d4",
+ "type": "add",
+ "after": "99b6c1f181f06109",
+ "date": 1776196859743
+ },
+ {
+ "type": "edit",
+ "id": "3ea052a063f9b7d4",
+ "item": {
+ "type": "paragraph",
+ "id": "3ea052a063f9b7d4",
+ "text": "A rendered diagram will have the following buttons:"
+ },
+ "date": 1776196876277
+ },
+ {
+ "type": "add",
+ "id": "ac5e05947cf1e110",
+ "item": {
+ "type": "paragraph",
+ "id": "ac5e05947cf1e110",
+ "text": "* Four arrows pointing outwards are \"Zoom\", they will open a pop-up dialog with a static larger version of the diagram."
+ },
+ "after": "3ea052a063f9b7d4",
+ "date": 1776196899794
+ },
+ {
+ "type": "add",
+ "id": "d6f91c2856da801f",
+ "item": {
+ "type": "paragraph",
+ "id": "d6f91c2856da801f",
+ "text": "* "
+ },
+ "after": "ac5e05947cf1e110",
+ "date": 1776196904894
+ },
+ {
+ "type": "remove",
+ "id": "ac5e05947cf1e110",
+ "date": 1776196909927
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "9d03b0bdd672f6a6"
+ },
+ "id": "9d03b0bdd672f6a6",
+ "type": "add",
+ "after": "d6f91c2856da801f",
+ "date": 1776196913677
+ },
+ {
+ "type": "edit",
+ "id": "9d03b0bdd672f6a6",
+ "item": {
+ "type": "markdown",
+ "id": "9d03b0bdd672f6a6",
+ "text": "* Four arrows pointing outward represent \"Zoom\", and will open a pop-up dialog with a larger and static version of the diagram.\n* An arrow pointing down with a line represents \"Download\", and will download a static SVG of the diagram.\n* A triangle pointing to the right represents \"Play\" and will start an animation showing how the diagram is influenced as properties change.\n* A triangle pointing to the right with a vertical line represents \"Step\", which will trigger a single step of the animation."
+ },
+ "date": 1776197041880
},
+ {
+ "type": "remove",
+ "id": "9d03b0bdd672f6a6",
+ "date": 1776197046780
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "cb5efb253f065849"
+ },
+ "id": "cb5efb253f065849",
+ "type": "add",
+ "after": "d6f91c2856da801f",
+ "date": 1776197047680
+ },
+ {
+ "type": "edit",
+ "id": "cb5efb253f065849",
+ "item": {
+ "type": "paragraph",
+ "id": "cb5efb253f065849",
+ "text": "* Four arrows pointing outward represent \"Zoom\", and will open a pop-up dialog with a larger and static version of the diagram.\n* An arrow pointing down with a line represents \"Download\", and will download a static SVG of the diagram.\n* A triangle pointing to the right represents \"Play\" and will start an animation showing how the diagram is influenced as properties change.\n* A triangle pointing to the right with a vertical line represents \"Step\", which will trigger a single step of the animation."
+ },
+ "date": 1776197051047
+ },
+ {
+ "type": "remove",
+ "id": "cb5efb253f065849",
+ "date": 1776197053447
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "22af2e69ff73e300"
+ },
+ "id": "22af2e69ff73e300",
+ "type": "add",
+ "after": "d6f91c2856da801f",
+ "date": 1776197054430
+ },
+ {
+ "type": "edit",
+ "id": "22af2e69ff73e300",
+ "item": {
+ "type": "markdown",
+ "id": "22af2e69ff73e300",
+ "text": "* Four arrows pointing outward represent \"Zoom\", and will open a pop-up dialog with a larger and static version of the diagram.\n* An arrow pointing down with a line represents \"Download\", and will download a static SVG of the diagram.\n* A triangle pointing to the right represents \"Play\" and will start an animation showing how the diagram is influenced as properties change.\n* A triangle pointing to the right with a vertical line represents \"Step\", which will trigger a single step of the animation."
+ },
+ "date": 1776197057414
+ },
+ {
+ "item": {
+ "type": "factory",
+ "id": "6fdc1e632b5f6f42"
+ },
+ "id": "6fdc1e632b5f6f42",
+ "type": "add",
+ "after": "22af2e69ff73e300",
+ "date": 1776197072664
+ },
+ {
+ "type": "edit",
+ "id": "6fdc1e632b5f6f42",
+ "item": {
+ "type": "paragraph",
+ "id": "6fdc1e632b5f6f42",
+ "text": "Ok"
+ },
+ "date": 1776197079047
+ },
+ {
+ "type": "remove",
+ "id": "6fdc1e632b5f6f42",
+ "date": 1776197081097
+ },
+ {
+ "type": "remove",
+ "id": "d6f91c2856da801f",
+ "date": 1776197086381
+ },
+ {
+ "type": "edit",
+ "id": "22af2e69ff73e300",
+ "item": {
+ "type": "markdown",
+ "id": "22af2e69ff73e300",
+ "text": "* Four arrows pointing outward represent \"Zoom\", and will open a pop-up dialog with a larger and static version of the diagram.\n* An arrow pointing down with a line represents \"Download\", and will download a static SVG of the diagram.\n* A triangle pointing to the right represents \"Play\" and will start an animation showing how the diagram is influenced as properties change.\n* A triangle pointing to the right with a vertical line represents \"Step\", which will trigger a single step of the animation.\n* A circular arrow represents \"Reset\", and will reset the diagram to its original state.\n* Two thick vertical bars represent \"Pause\", and will pause the animation."
+ },
+ "date": 1776197143799
+ }
]
}
diff --git a/src/causalloop.js b/src/causalloop.js
index afd173e..e13b78a 100644
--- a/src/causalloop.js
+++ b/src/causalloop.js
@@ -1,22 +1,36 @@
-import {forceSimulation, forceLink, forceCenter, forceManyBody, forceCollide } from "d3-force";
+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",
- kTheme: {
- positive: '#23C17C',
- negative: '#FA2B00',
- node: '#0F261F',
- halo: '#FFFFFF',
+ styleSheet: "/plugins/causalloop/causalloop.css",
+ theme: {
+ positive: "#23C17C",
+ negative: "#FA2B00",
+ node: "#0F261F",
+ halo: "#FFFFFF",
},
- kDimensions: {
+ icons: {
+ play: `data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="grey"><path d="M8 5v14l11-7z"/></svg>`,
+ pause: `data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="grey"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`,
+ step: `data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="grey"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>`,
+ reset: `data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="grey"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>`,
+ download: `data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="grey"><g><rect fill="none" height="24" width="24"/></g><g><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g></svg>`,
+ zoom: `data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24"><g><rect fill="none" height="24" width="24"/></g><g><g><g><path fill="grey" d="M15,3l2.3,2.3l-2.89,2.87l1.42,1.42L18.7,6.7L21,9V3H15z M3,9l2.3-2.3l2.87,2.89l1.42-1.42L6.7,5.3L9,3H3V9z M9,21 l-2.3-2.3l2.89-2.87l-1.42-1.42L5.3,17.3L3,15v6H9z M21,15l-2.3,2.3l-2.87-2.89l-1.42,1.42l2.89,2.87L15,21h6V15z"/></g></g></g></svg>`,
+ },
+ dimensions: {
full: {
width: 806,
height: 620,
- minSize: 16,
- initialSize: 32,
- maxSize: 64,
+ minSize: 11,
+ initialSize: 14,
+ maxSize: 22,
growthFactor: 0.2,
collideRadius: 2,
collideExtra: 25,
@@ -27,15 +41,17 @@ const internals = {
linkDistance: 180,
chargeStrength: -700,
maxLabelWidth: 10,
- nodeFontSize: '14px',
- edgeFontSize: '11px',
+ edgeFontSize: "11px",
+ flowInterval: 1500,
+ flowDuration: 1200,
+ dotRadius: 4,
},
thumbnail: {
width: 403,
height: 310,
- minSize: 8,
- initialSize: 16,
- maxSize: 32,
+ minSize: 9,
+ initialSize: 11,
+ maxSize: 17,
growthFactor: 0.1,
collideRadius: 2,
collideExtra: 10,
@@ -46,234 +62,455 @@ const internals = {
linkDistance: 80,
chargeStrength: -300,
maxLabelWidth: 10,
- nodeFontSize: '12px',
- edgeFontSize: '10px',
- }
+ edgeFontSize: "10px",
+ flowInterval: 1500,
+ flowDuration: 1200,
+ dotRadius: 3,
+ },
},
- kLineRe: /([^\s].*?)([+-])>\s*([^\s].*?)(?:\[((?:\d+(?:\.\d+)?)|(?:\.\d+))\])?$/,
+ 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<String>
- * - connections: Object<String, Array<{value: Number, node: String}>>
- * - state: Object<String, Number>
- */
+ * Parses the causal loop diagram line by line and generates an object
+ * that looks like this:
+ * - nodes: Set<String>
+ * - connections: Object<String, Array<{weight: Number, target: String}>>
+ * - state: Object<String, Number>
+ */
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();
+ const connections = {};
+ const seenConnections = new Set();
+ for (const rawLine of text.split("\n")) {
+ const matches = rawLine.match(internals.lineRegex);
+ if (matches === null) continue;
- // Determine the value of the connection.
- let value = parseFloat(matches[4]);
- if (Number.isNaN(value)) {
- value = 1;
- }
- if (direction === "-") {
- value = value * -1;
- }
+ const source = matches[1]?.trim();
+ const target = matches[3]?.trim();
+ const direction = matches[2]?.trim();
+ if (!source || !target) continue;
- // 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);
+ const connectionKey = `${source}\0${target}`;
+ if (seenConnections.has(connectionKey)) continue;
+ seenConnections.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
+ 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 };
},
- renderToSVG($element, parsedDiagram, { width, height, collideRadius, collideExtra,
- haloWidth, edgeLabelHaloWidth, edgeWidth, curveOffset, linkDistance,
- chargeStrength, maxLabelWidth, nodeFontSize, edgeFontSize }) {
- const nodeId = (node) => typeof node === 'object' ? node.id : node;
- const { kTheme } = internals;
- const colorFor = (datum) => datum.value > 0 ? kTheme.positive : kTheme.negative;
+ /**
+ * 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 colorFor = (link) =>
+ link.weight > 0 ? theme.positive : theme.negative;
+ 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;
function wrapLabel(text) {
if (text.length <= maxLabelWidth) return [text];
const words = text.split(/\s+/);
const lines = [];
let currentLine = words[0];
- for (let i = 1; i < words.length; i++) {
- if (currentLine.length + 1 + words[i].length <= maxLabelWidth) {
- currentLine += ' ' + words[i];
+ 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[i];
+ currentLine = words[index];
}
}
lines.push(currentLine);
return lines;
}
- const nodes = [...parsedDiagram.nodes].map(id => {
+ const graphNodes = [...parsedDiagram.nodes].map((id) => {
const lines = wrapLabel(id);
- const maxLineLength = Math.max(...lines.map(line => line.length));
+ const maxLineLength = Math.max(...lines.map((line) => line.length));
return { id, lines, maxLineLength };
});
- const links = Object.entries(parsedDiagram.connections)
- .flatMap(([source, links]) =>
- links.map(({ node: target, value }) => ({ source, target, value })));
+ const graphLinks = Object.entries(parsedDiagram.connections).flatMap(
+ ([source, outgoing]) =>
+ outgoing.map(({ target, weight }) => ({
+ source,
+ target,
+ weight,
+ })),
+ );
- // Build a set of bidirectional pairs for curve detection
- const linkKeys = new Set(links.map(link => `${nodeId(link.source)}\0${nodeId(link.target)}`));
- const isBidirectional = (link) => linkKeys.has(`${nodeId(link.target)}\0${nodeId(link.source)}`);
+ const linkKeys = new Set(
+ graphLinks.map((link) => `${link.source}\0${link.target}`),
+ );
+ const isBidirectional = (link) =>
+ linkKeys.has(`${link.target.id}\0${link.source.id}`);
+
+ const svg = d3.select($element).attr("viewBox", [0, 0, width, height]);
+ svg.selectAll("*").remove();
- const svg = d3.select($element).attr('viewBox', [0, 0, width, height]);
- svg.selectAll('*').remove();
+ drawMarkers();
+ const edge = drawEdges();
+ const edgeLabel = drawEdgeLabels();
+ const node = drawNodes();
+ const dotGroup = svg.append("g").attr("class", "flow-dots");
- // Define arrow markers for positive and negative edges
- const defs = svg.append('defs');
- for (const [id, color] of [['arrow-positive', kTheme.positive], ['arrow-negative', kTheme.negative]]) {
- defs.append('marker')
- .attr('id', 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', color);
+ function drawMarkers() {
+ 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);
}
- // Edge paths
- const edgeGroup = svg.append('g').attr('fill', 'none');
- const edge = edgeGroup.selectAll('path')
- .data(links)
- .join('path')
- .attr('stroke', datum => colorFor(datum))
- .attr('stroke-width', edgeWidth)
- .attr('marker-end', datum => datum.value > 0 ? 'url(#arrow-positive)' : 'url(#arrow-negative)');
+ function drawEdges() {
+ return svg
+ .append("g")
+ .attr("fill", "none")
+ .selectAll("path")
+ .data(graphLinks)
+ .join("path")
+ .attr("stroke", colorFor)
+ .attr("stroke-width", edgeWidth)
+ .attr("marker-end", (link) =>
+ link.weight > 0
+ ? "url(#arrow-positive)"
+ : "url(#arrow-negative)",
+ );
+ }
- // Edge labels
- const edgeLabelGroup = svg.append('g')
- .attr('font-family', 'serif')
- .attr('font-size', edgeFontSize);
- const edgeLabel = edgeLabelGroup.selectAll('text')
- .data(links)
- .join('text')
- .attr('text-anchor', 'middle')
- .attr('dy', '-4')
- .attr('fill', datum => colorFor(datum))
- .attr('stroke', kTheme.halo)
- .attr('stroke-width', edgeLabelHaloWidth)
- .attr('paint-order', 'stroke')
- .text(datum => datum.value > 0 ? 'S(+)' : 'O(-)');
+ function drawEdgeLabels() {
+ 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", colorFor)
+ .attr("stroke", theme.halo)
+ .attr("stroke-width", edgeLabelHaloWidth)
+ .attr("paint-order", "stroke")
+ .text((link) => (link.weight > 0 ? "S(+)" : "O(-)"));
+ }
- // Node labels — long labels wrap into multiple <tspan> lines
- const lineHeight = 1.2;
- const node = svg.append('g')
- .attr('font-family', 'serif')
- .attr('font-size', nodeFontSize)
- .selectAll('text')
- .data(nodes)
- .join('text')
- .attr('text-anchor', 'middle')
- .attr('fill', kTheme.node)
- .attr('stroke', kTheme.halo)
- .attr('stroke-width', haloWidth)
- .attr('paint-order', 'stroke')
- .attr('class', 'label')
- .attr('data-title', datum => datum.id)
- .each(function(datum) {
- const element = d3.select(this);
- const verticalOffset = 0.35 - (datum.lines.length - 1) * lineHeight / 2;
- datum.lines.forEach((line, i) => {
- element.append('tspan')
- .attr('dy', i === 0 ? `${verticalOffset}em` : `${lineHeight}em`)
- .text(line);
+ function drawNodes() {
+ 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) * lineHeight) / 2;
+ graphNode.lines.forEach((line, index) => {
+ element
+ .append("tspan")
+ .attr(
+ "dy",
+ index === 0
+ ? `${verticalOffset}em`
+ : `${lineHeight}em`,
+ )
+ .text(line);
+ });
});
- });
+ }
+
+ 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 simulation = forceSimulation(nodes)
- .force('link', forceLink(links).id(datum => datum.id).distance(linkDistance))
- .force('charge', forceManyBody().strength(chargeStrength))
- .force('center', forceCenter(width / 2, height / 2))
- .force('collide', forceCollide().radius(datum => datum.maxLineLength * collideRadius + collideExtra))
- .on('tick', ticked);
+ let flowTimer = null;
+ let firingNodes = null;
- function labelPosition(datum) {
- const midX = (datum.source.x + datum.target.x) / 2;
- const midY = (datum.source.y + datum.target.y) / 2;
- if (!isBidirectional(datum)) return { x: midX, y: midY };
- const dx = datum.target.x - datum.source.x;
- const dy = datum.target.y - datum.source.y;
- const length = Math.sqrt(dx * dx + dy * dy) || 1;
+ 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 = edge
+ .filter(
+ (link) =>
+ link.source.id === sourceId &&
+ link.target.id === targetId,
+ )
+ .node();
+ 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) {
+ 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;
+ animateDot(sourceId, targetId, targetDelta);
+ nextFiring.set(
+ targetId,
+ (nextFiring.get(targetId) ?? 0) + targetDelta,
+ );
+ }
+ }
+ firingNodes = nextFiring;
+ }
+
+ function 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
+ );
+ }
+
+ function computeEdgePath(link) {
+ 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}`;
+ }
+
+ function labelPosition(link) {
+ 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: midX + -(dy / length) * curveOffset,
- y: midY + (dx / length) * curveOffset
+ x: midpointX + -(deltaY / length) * curveOffset,
+ y: midpointY + (deltaX / length) * curveOffset,
};
}
function ticked() {
- // Clamp nodes inside the viewBox
const padding = 40;
- nodes.forEach(datum => {
- datum.x = Math.max(padding, Math.min(width - padding, datum.x));
- datum.y = Math.max(padding, Math.min(height - padding, datum.y));
+ 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', datum => {
- const sourceX = datum.source.x, sourceY = datum.source.y;
- const targetX = datum.target.x, targetY = datum.target.y;
- const dx = targetX - sourceX, dy = targetY - sourceY;
- const length = Math.sqrt(dx * dx + dy * dy) || 1;
-
- // Shorten endpoints based on approach angle — text is wide but short
- const unitX = dx / length, unitY = dy / length;
- const absUX = Math.abs(unitX), absUY = Math.abs(unitY);
- const sourceHalfWidth = (datum.source.maxLineLength || 4) * collideRadius;
- const targetHalfWidth = (datum.target.maxLineLength || 4) * collideRadius;
- const sourceHalfHeight = 8 * (datum.source.lines?.length || 1);
- const targetHalfHeight = 8 * (datum.target.lines?.length || 1);
- const sourceMargin = absUX * sourceHalfWidth + absUY * sourceHalfHeight + 4;
- const targetMargin = absUX * targetHalfWidth + absUY * targetHalfHeight + 8;
- const x1 = sourceX + unitX * sourceMargin, y1 = sourceY + unitY * sourceMargin;
- const x2 = targetX - unitX * targetMargin, y2 = targetY - unitY * targetMargin;
-
- if (isBidirectional(datum)) {
- const perpX = -unitY * curveOffset, perpY = unitX * curveOffset;
- const controlX = (x1 + x2) / 2 + perpX, controlY = (y1 + y2) / 2 + perpY;
- return `M${x1},${y1} Q${controlX},${controlY} ${x2},${y2}`;
- }
- return `M${x1},${y1} L${x2},${y2}`;
+ edge.attr("d", computeEdgePath);
+ edgeLabel
+ .attr("x", (link) => labelPosition(link).x)
+ .attr("y", (link) => labelPosition(link).y);
+ node.attr("x", (graphNode) => graphNode.x).attr(
+ "y",
+ (graphNode) => graphNode.y,
+ );
+ node.selectAll("tspan").attr("x", function () {
+ return d3.select(this.parentNode).datum().x;
});
+ }
- edgeLabel.each(function(datum) {
- const position = labelPosition(datum);
- d3.select(this).attr('x', position.x).attr('y', position.y);
- });
+ function pause() {
+ if (flowTimer) {
+ flowTimer.stop();
+ flowTimer = null;
+ }
+ dotGroup.selectAll("*").interrupt().remove();
+ firingNodes = null;
+ }
- node.each(function(datum) {
- const element = d3.select(this);
- element.attr('x', datum.x).attr('y', datum.y);
- element.selectAll('tspan').attr('x', datum.x);
- });
+ 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(); }
+ 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,
};
},
@@ -287,13 +524,16 @@ const internals = {
</div>`;
},
- // Starts a download of the image.
+ /**
+ * Wraps the SVG with the prelude and xml version and starts a download.
+ */
download(filename, text) {
const svgWithNamespace = text.replace(
"<svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"',
);
- const svgContent = '<?xml version="1.0" encoding="UTF-8"?>\n' + svgWithNamespace;
+ const svgContent =
+ '<?xml version="1.0" encoding="UTF-8"?>\n' + svgWithNamespace;
const blob = new Blob([svgContent], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const element = document.createElement("a");
@@ -306,7 +546,9 @@ const internals = {
URL.revokeObjectURL(url);
},
- // Replaces the image in a window. Intended for use in a dialog.
+ /**
+ * Replaces the image in a dialog with the new image data.
+ */
replaceImage(window, imageData) {
try {
const $container = window.document.querySelector("main");
@@ -318,6 +560,109 @@ const internals = {
);
}
},
+
+ // SVG Helpers /////////////////////////////////////////////////////////////
+
+ /**
+ * Creates an SVG element
+ */
+ createSVG() {
+ return document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ },
+
+ /**
+ * Creates a pre-rendered SVG by settling the animation.
+ */
+ renderStandaloneSVG(item) {
+ const svg = internals.createSVG();
+ const { settle } = internals.renderToSVG(
+ svg,
+ internals.parse(item.text),
+ internals.dimensions.full,
+ );
+ settle();
+ return svg;
+ },
+
+ // 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.
+ 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.
+ // It downloads the SVG file.
+ onDownload($item, item) {
+ const svg = internals.renderStandaloneSVG(item);
+ const slug = $item.parents(".page").attr("id");
+ internals.download(`${slug}.svg`, svg.outerHTML);
+ },
+
+ // Triggered when the zoom button is clicked.
+ // It opens the dialog and loads the image
+ onZoom(event, 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 = internals.renderStandaloneSVG(item).outerHTML;
+ internals.onDialogReady(dialog, () =>
+ 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);
+ },
};
/**
@@ -325,14 +670,14 @@ const internals = {
*/
const emit = function emit($item) {
const hasStyleSheet = [...document.styleSheets].some((styleSheet) =>
- styleSheet.href?.endsWith(internals.kStyleSheet),
+ styleSheet.href?.endsWith(internals.styleSheet),
);
if (!hasStyleSheet) {
console.log("Adding causalloop stylesheet.");
const link = document.createElement("link");
link.rel = "stylesheet";
- link.href = internals.kStyleSheet;
+ link.href = internals.styleSheet;
link.type = "text/css";
document.getElementsByTagName("head")[0].appendChild(link);
}
@@ -348,109 +693,75 @@ const emit = function emit($item) {
* @param {Object} item the configuration for that particular object.
*/
const bind = async function bind($item, item) {
- $item.on("dblclick", () => {
- return wiki.textEditor($item, item);
- });
+ let renderer = null;
+ $item.on("dblclick", () => wiki.textEditor($item, item));
$item.on("click", "a", async (event) => {
- const { currentTarget } = event;
- const action = currentTarget.dataset?.action;
-
- if (!action) {
- return;
- }
+ const action = event.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 { settle } = internals.renderToSVG(
- svg,
- internals.parse(item.text),
- internals.kDimensions.full,
- );
- settle();
- const slug = $item.parents(".page").attr("id");
- internals.download(`${slug}.svg`, svg.outerHTML);
- }
+ internals.onDownload($item, item);
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 { settle } = internals.renderToSVG(
- svg,
- internals.parse(item.text),
- internals.kDimensions.full,
- );
- settle();
- const imageData = svg.outerHTML;
-
- // 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 },
- );
- }
- }
+ 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 = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- internals.renderToSVG(
+ const $element = internals.createSVG();
+ renderer = internals.renderToSVG(
$element,
internals.parse(item.text),
- internals.kDimensions.thumbnail,
+ internals.dimensions.thumbnail,
+ {
+ onChange() {
+ const dirty = renderer?.isDirty() ?? false;
+ $item
+ .find('a[data-action="reset"]')
+ .parent()
+ .css("display", dirty ? "" : "none");
+ },
+ },
);
$item.find(".viewer").html(`
<article class="causalloop thumbnail"></article>
<nav class="actions">
<menu>
- <li><a href="#" data-action="download" title="Download"><img width="18" height="18" alt="download" src='data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="grey"><g><rect fill="none" height="24" width="24"/></g><g><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g></svg>'></a></li>
- <li><a href="#" data-action="zoom" title="Zoom"><img width="18" height="18" alt="toggle zoom" src='data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24"><g><rect fill="none" height="24" width="24"/></g><g><g><g><path fill="grey" d="M15,3l2.3,2.3l-2.89,2.87l1.42,1.42L18.7,6.7L21,9V3H15z M3,9l2.3-2.3l2.87,2.89l1.42-1.42L6.7,5.3L9,3H3V9z M9,21 l-2.3-2.3l2.89-2.87l-1.42-1.42L5.3,17.3L3,15v6H9z M21,15l-2.3,2.3l-2.87-2.89l-1.42,1.42l2.89,2.87L15,21h6V15z"/></g></g></g></svg>'></a></li>
+ <li style="display:none"><a href="#" data-action="reset" title="Reset"><img width="18" height="18" alt="reset" src='${internals.icons.reset}'></a></li>
+ <li><a href="#" data-action="play" title="Play/Pause"><img width="18" height="18" alt="play/pause" src='${internals.icons.play}'></a></li>
+ <li><a href="#" data-action="step" title="Step"><img width="18" height="18" alt="step" src='${internals.icons.step}'></a></li>
+ <li><a href="#" data-action="download" title="Download"><img width="18" height="18" alt="download" src='${internals.icons.download}'></a></li>
+ <li><a href="#" data-action="zoom" title="Zoom"><img width="18" height="18" alt="toggle zoom" src='${internals.icons.zoom}'></a></li>
</menu>
</nav>
`);
$item.find(".causalloop").append($element);
- $item.find('svg text.label').on('click', function(event) {
+ $item.find("svg text.label").on("click", function (event) {
const title = this.dataset.title;
- let $page = $item.parents('.page')
- wiki.pageHandler.context = wiki.lineup.atKey($page.data('key')).getContext()
- wiki.doInternalLink(title, event.shiftKey ? null : $page)
+ const $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));
+ } catch (error) {
+ console.log("Failed to parse causal loop diagram: ", error);
+ $item.html(internals.message(error.message));
}
};
@@ -468,4 +779,5 @@ const expand = function (text) {
return DOMPurify.sanitize(text);
};
-export const causalloop = typeof window === "undefined" ? { expand } : undefined;
+export const causalloop =
+ typeof window === "undefined" ? { expand } : undefined;