diff options
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | client/causalloop.css | 3 | ||||
| -rw-r--r-- | pages/about-causal-loop-plugin | 369 | ||||
| -rw-r--r-- | src/causalloop.js | 858 |
4 files changed, 953 insertions, 278 deletions
@@ -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; |