aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/causalloop.js1350
1 files changed, 640 insertions, 710 deletions
diff --git a/src/causalloop.js b/src/causalloop.js
index 80360fa..79d803c 100644
--- a/src/causalloop.js
+++ b/src/causalloop.js
@@ -8,754 +8,681 @@ import {
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,<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: 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+))\])?$/,
+////////////////////////////////////////////////////////////////////////////////
+// Constants ///////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
- /**
- * 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 = {};
- const seenConnections = new Set();
- for (const rawLine of text.split("\n")) {
- const matches = rawLine.match(internals.lineRegex);
- if (matches === null) continue;
+const styleSheet = "/plugins/causalloop/causalloop.css";
- 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);
+const theme = {
+ positive: "#23C17C",
+ negative: "#FA2B00",
+ node: "#0F261F",
+ halo: "#FFFFFF",
+};
- let weight = parseFloat(matches[4]) || 1;
- if (direction === "-") weight = -weight;
+const 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>`,
+};
- nodes.add(source);
- nodes.add(target);
- (connections[source] ??= []).push({ weight, target });
- }
- const state = Object.fromEntries([...nodes].map((id) => [id, 1]));
- return { nodes, connections, state };
+const dimensions = {
+ full: {
+ width: 806,
+ height: 620,
+ minSize: 11,
+ initialSize: 14,
+ maxSize: 22,
+ collideRadius: 2,
+ collideExtra: 25,
+ haloWidth: 4,
+ edgeLabelHaloWidth: 3,
+ edgeWidth: 1.5,
+ curveOffset: 20,
+ linkDistance: 180,
+ chargeStrength: -700,
+ maxLabelWidth: 10,
+ edgeFontSize: "11px",
},
-
- /**
- * 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;
+ thumbnail: {
+ width: 403,
+ height: 310,
+ minSize: 9,
+ initialSize: 11,
+ maxSize: 17,
+ collideRadius: 2,
+ collideExtra: 10,
+ haloWidth: 3,
+ edgeLabelHaloWidth: 2,
+ edgeWidth: 1,
+ curveOffset: 12,
+ linkDistance: 80,
+ chargeStrength: -300,
+ maxLabelWidth: 10,
+ edgeFontSize: "10px",
},
-
- /**
- * 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 };
+ // Animation config. Only the thumbnail animates — the zoom/download
+ // path uses settle() to pre-render a static SVG and never fires dots.
+ animation: {
+ growthFactor: 0.1,
+ flowInterval: 1500,
+ flowDuration: 1200,
+ dotRadius: 3,
},
+};
- /**
- * Appends the positive/negative arrow markers to the SVG's <defs>.
- */
- 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);
- },
+const lineRegex =
+ /([^\s].*?)([+-])>\s*([^\s].*?)(?:\[((?:\d+(?:\.\d+)?)|(?:\.\d+))\])?$/;
- /**
- * 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)",
- );
- },
+////////////////////////////////////////////////////////////////////////////////
+// Parsing /////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
- /**
- * 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(-)"));
- },
+// 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>
+function parse(text) {
+ const nodes = new Set();
+ const connections = {};
+ const seenConnections = new Set();
+ for (const rawLine of text.split("\n")) {
+ const matches = rawLine.match(lineRegex);
+ if (matches === null) continue;
- /**
- * 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);
- });
- });
- },
+ const source = matches[1]?.trim();
+ const target = matches[3]?.trim();
+ const direction = matches[2]?.trim();
+ if (!source || !target) continue;
- /**
- * 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;
+ const connectionKey = `${source}\0${target}`;
+ if (seenConnections.has(connectionKey)) continue;
+ seenConnections.add(connectionKey);
- 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}`;
- },
+ let weight = parseFloat(matches[4]) || 1;
+ if (direction === "-") weight = -weight;
- /**
- * 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,
- };
- },
+ nodes.add(source);
+ nodes.add(target);
+ (connections[source] ??= []).push({ weight, target });
+ }
+ const state = Object.fromEntries([...nodes].map((id) => [id, 1]));
+ return { nodes, connections, state };
+}
- /**
- * 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;
+////////////////////////////////////////////////////////////////////////////////
+// Render Helpers //////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
- 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;
+// Wraps a label string into multiple lines so no line exceeds
+// maxLabelWidth characters.
+function wrapLabel(text, maxLabelWidth) {
+ if (text.length <= maxLabelWidth) return [text];
+ const words = text.split(/\s+/);
+ const lines = [];
+ let currentLine = words[0];
+ for (let index = 1; index < words.length; index++) {
+ if (currentLine.length + 1 + words[index].length <= maxLabelWidth) {
+ currentLine += " " + words[index];
+ } else {
+ lines.push(currentLine);
+ currentLine = words[index];
+ }
+ }
+ lines.push(currentLine);
+ return lines;
+}
- const { graphNodes, graphLinks, isBidirectional } =
- internals.buildGraphData(parsedDiagram, maxLabelWidth);
+// Builds the d3 graph data (nodes, links, and a bidirectional predicate)
+// from a parsed diagram.
+function buildGraphData(parsedDiagram, maxLabelWidth) {
+ const graphNodes = [...parsedDiagram.nodes].map((id) => {
+ const lines = wrapLabel(id, maxLabelWidth);
+ const maxLineLength = Math.max(...lines.map((line) => line.length));
+ return { id, lines, maxLineLength };
+ });
+ const graphLinks = Object.entries(parsedDiagram.connections).flatMap(
+ ([source, outgoing]) =>
+ outgoing.map(({ target, weight }) => ({ source, target, weight })),
+ );
+ const linkKeys = new Set(
+ graphLinks.map((link) => `${link.source}\0${link.target}`),
+ );
+ for (const link of graphLinks) {
+ link.bidirectional = linkKeys.has(`${link.target}\0${link.source}`);
+ }
+ return { graphNodes, graphLinks };
+}
- const svg = d3.select($element).attr("viewBox", [0, 0, width, height]);
- svg.selectAll("*").remove();
+// Appends the positive/negative arrow markers to the SVG's <defs>.
+function drawMarkers(svg) {
+ svg.append("defs")
+ .selectAll("marker")
+ .data([
+ { id: "arrow-positive", color: theme.positive },
+ { id: "arrow-negative", color: theme.negative },
+ ])
+ .join("marker")
+ .attr("id", (marker) => marker.id)
+ .attr("viewBox", "0 0 10 6")
+ .attr("refX", 10)
+ .attr("refY", 3)
+ .attr("markerWidth", 10)
+ .attr("markerHeight", 6)
+ .attr("orient", "auto")
+ .append("path")
+ .attr("d", "M0,0 L10,3 L0,6 Z")
+ .attr("fill", (marker) => marker.color);
+}
- 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,
+// Draws the edges group and returns the path selection.
+function drawEdges(svg, graphLinks, { edgeWidth }) {
+ return svg
+ .append("g")
+ .attr("fill", "none")
+ .selectAll("path")
+ .data(graphLinks)
+ .join("path")
+ .attr("stroke", (link) =>
+ link.weight > 0 ? theme.positive : theme.negative,
+ )
+ .attr("stroke-width", edgeWidth)
+ .attr("marker-end", (link) =>
+ link.weight > 0 ? "url(#arrow-positive)" : "url(#arrow-negative)",
);
- const dotGroup = svg.append("g").attr("class", "flow-dots");
+}
+
+// Draws the edge-label group and returns the text selection.
+function drawEdgeLabels(svg, graphLinks, { edgeFontSize, edgeLabelHaloWidth }) {
+ return svg
+ .append("g")
+ .attr("font-family", "serif")
+ .attr("font-size", edgeFontSize)
+ .selectAll("text")
+ .data(graphLinks)
+ .join("text")
+ .attr("text-anchor", "middle")
+ .attr("dy", "-4")
+ .attr("fill", (link) =>
+ link.weight > 0 ? theme.positive : theme.negative,
+ )
+ .attr("stroke", theme.halo)
+ .attr("stroke-width", edgeLabelHaloWidth)
+ .attr("paint-order", "stroke")
+ .text((link) => (link.weight > 0 ? "S(+)" : "O(-)"));
+}
- const pathByKey = new Map();
- edge.each(function (link) {
- pathByKey.set(`${link.source}\0${link.target}`, this);
+// Draws the node-label group (with wrapped tspans) and returns the text
+// selection. sizeFor is a callback because font-size tracks live state.
+function drawNodes(svg, graphNodes, sizeFor, { haloWidth }) {
+ const lineHeight = 1.2;
+ return svg
+ .append("g")
+ .attr("font-family", "serif")
+ .selectAll("text")
+ .data(graphNodes)
+ .join("text")
+ .attr("text-anchor", "middle")
+ .attr("fill", theme.node)
+ .attr("stroke", theme.halo)
+ .attr("stroke-width", haloWidth)
+ .attr("paint-order", "stroke")
+ .attr("font-size", (graphNode) => `${sizeFor(graphNode)}px`)
+ .attr("class", "label")
+ .attr("data-title", (graphNode) => graphNode.id)
+ .each(function (graphNode) {
+ const element = d3.select(this);
+ const verticalOffset =
+ 0.35 - ((graphNode.lines.length - 1) * 1.2) / 2;
+ graphNode.lines.forEach((line, index) => {
+ element
+ .append("tspan")
+ .attr(
+ "dy",
+ index === 0 ? `${verticalOffset}em` : `${lineHeight}em`,
+ )
+ .text(line);
+ });
});
+}
- 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);
+// Computes the SVG path string for a causal link, inset by each node's
+// halo and curved outward for bidirectional links.
+function computeEdgePath(link, scaleFactor, collideRadius, curveOffset) {
+ const { source, target } = link;
+ const deltaX = target.x - source.x;
+ const deltaY = target.y - source.y;
+ const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
+ const unitX = deltaX / length;
+ const unitY = deltaY / length;
+ const absUnitX = Math.abs(unitX);
+ const absUnitY = Math.abs(unitY);
+ const halfW = (node) =>
+ node.maxLineLength * collideRadius * scaleFactor(node);
+ const halfH = (node) => 8 * node.lines.length * scaleFactor(node);
+ const sourceMargin =
+ absUnitX * halfW(source) + absUnitY * halfH(source) + 4;
+ const targetMargin =
+ absUnitX * halfW(target) + absUnitY * halfH(target) + 8;
+ const startX = source.x + unitX * sourceMargin;
+ const startY = source.y + unitY * sourceMargin;
+ const endX = target.x - unitX * targetMargin;
+ const endY = target.y - unitY * targetMargin;
- let flowTimer = null;
- let firingNodes = null;
+ if (!link.bidirectional) {
+ return `M${startX},${startY} L${endX},${endY}`;
+ }
+ const perpendicularX = -unitY * curveOffset;
+ const perpendicularY = unitX * curveOffset;
+ const controlX = (startX + endX) / 2 + perpendicularX;
+ const controlY = (startY + endY) / 2 + perpendicularY;
+ return `M${startX},${startY} Q${controlX},${controlY} ${endX},${endY}`;
+}
- function updateSizes() {
- node.attr("font-size", (graphNode) => `${sizeFor(graphNode)}px`);
- simulation.force("collide").radius(radiusFor);
- simulation.alpha(0.2).restart();
- options.onChange?.();
- }
+// Returns the xy position for an edge label, offset outward for
+// bidirectional links so labels don't overlap the curves.
+function labelPosition(link, curveOffset) {
+ const midpointX = (link.source.x + link.target.x) / 2;
+ const midpointY = (link.source.y + link.target.y) / 2;
+ if (!link.bidirectional) return { x: midpointX, y: midpointY };
+ const deltaX = link.target.x - link.source.x;
+ const deltaY = link.target.y - link.source.y;
+ const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
+ return {
+ x: midpointX + -(deltaY / length) * curveOffset,
+ y: midpointY + (deltaX / length) * curveOffset,
+ };
+}
- function isDirty() {
- return [...parsedDiagram.nodes].some(
- (id) => parsedDiagram.state[id] !== 1,
- );
- }
+////////////////////////////////////////////////////////////////////////////////
+// Animation ///////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
- function applyDelta(id, delta) {
- const next = parsedDiagram.state[id] * (1 + delta);
- parsedDiagram.state[id] = Math.max(
- minStateFactor,
- Math.min(maxStateFactor, next),
- );
- }
+// Builds the cascading flow animator that fires dots along edges and
+// updates node state. Owns the play/pause/step/reset lifecycle. Created
+// once per renderToSVG call; only the thumbnail path ever calls play().
+function createFlowAnimator({
+ parsedDiagram,
+ graphLinks,
+ dotGroup,
+ animation,
+ minStateFactor,
+ maxStateFactor,
+ onSizeChange,
+}) {
+ const { dotRadius, flowDuration, flowInterval, growthFactor } = animation;
+ const outgoingByNode = new Map();
+ for (const link of graphLinks) {
+ const sourceId = link.source.id ?? link.source;
+ const list = outgoingByNode.get(sourceId) ?? [];
+ list.push(link);
+ outgoingByNode.set(sourceId, list);
+ }
- 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();
- });
- }
+ let flowTimer = null;
+ let firingNodes = new Map();
- 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;
- }
+ const applyDelta = (id, delta) => {
+ const next = parsedDiagram.state[id] * (1 + delta);
+ parsedDiagram.state[id] = Math.max(
+ minStateFactor,
+ Math.min(maxStateFactor, next),
+ );
+ };
- 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),
- );
+ const animateDot = (link, delta) => {
+ const pathElement = link.pathElement;
+ if (!pathElement) return;
+ const length = pathElement.getTotalLength();
+ if (!length) return;
+ const targetId = link.target.id ?? link.target;
+ const dot = dotGroup
+ .append("circle")
+ .attr("r", dotRadius)
+ .attr("fill", delta >= 0 ? theme.positive : theme.negative)
+ .attr("stroke", theme.halo)
+ .attr("stroke-width", 1);
+ dot.transition()
+ .duration(flowDuration)
+ .ease(d3.easeLinear)
+ .attrTween("transform", () => (progress) => {
+ const point = pathElement.getPointAtLength(progress * length);
+ return `translate(${point.x},${point.y})`;
+ })
+ .on("end", () => {
+ dot.remove();
+ applyDelta(targetId, delta);
+ onSizeChange();
});
+ };
- 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,
+ const tick = () => {
+ if (firingNodes.size === 0) {
+ // re-seed from the first node when the cascade dies out
+ const seedNodeId = parsedDiagram.nodes.values().next().value;
+ if (!seedNodeId) return;
+ applyDelta(seedNodeId, growthFactor);
+ firingNodes = new Map([[seedNodeId, growthFactor]]);
+ onSizeChange();
+ }
+ const nextFiring = new Map();
+ for (const [sourceId, sourceDelta] of firingNodes) {
+ const outgoing = outgoingByNode.get(sourceId) ?? [];
+ for (const link of outgoing) {
+ const targetDelta = sourceDelta * link.weight;
+ if (!targetDelta) continue;
+ animateDot(link, targetDelta);
+ const targetId = link.target.id ?? link.target;
+ nextFiring.set(
+ targetId,
+ (nextFiring.get(targetId) ?? 0) + targetDelta,
);
- node.attr("x", (graphNode) => graphNode.x)
- .attr("y", (graphNode) => graphNode.y)
- .each(function (graphNode) {
- d3.select(this).selectAll("tspan").attr("x", graphNode.x);
- });
+ }
}
+ firingNodes = nextFiring;
+ };
- function pause() {
- if (flowTimer) {
- flowTimer.stop();
- flowTimer = null;
- }
- dotGroup.selectAll("*").interrupt().remove();
- firingNodes = null;
+ const pause = () => {
+ if (flowTimer) {
+ flowTimer.stop();
+ flowTimer = null;
}
+ dotGroup.selectAll("*").interrupt().remove();
+ firingNodes = new Map();
+ };
- function reset() {
- pause();
- for (const id of parsedDiagram.nodes) {
- parsedDiagram.state[id] = 1;
- }
- updateSizes();
+ const reset = () => {
+ pause();
+ for (const id of parsedDiagram.nodes) {
+ parsedDiagram.state[id] = 1;
}
+ onSizeChange();
+ };
- 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,
- };
- },
+ return {
+ play() {
+ if (flowTimer) return;
+ tick();
+ flowTimer = d3.interval(tick, flowInterval);
+ },
+ pause,
+ step: tick,
+ reset,
+ isPlaying: () => flowTimer !== null,
+ isDirty: () =>
+ [...parsedDiagram.nodes].some(
+ (id) => parsedDiagram.state[id] !== 1,
+ ),
+ };
+}
- // Returns HTML that shows a message in the wiki.
- message(text) {
- return `
- <div class="viewer" data-item="viewer" style="width:98%">
- <div style="width:80%; padding:8px; color:gray; background-color:#eee; margin:0 auto; text-align:center">
- <i>${text}</i>
- </div>
- </div>`;
- },
+////////////////////////////////////////////////////////////////////////////////
+// Main Renderer ///////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
- /**
- * 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"',
+// Given an existing SVG element, a parsed diagram (see #parse()), and a
+// layout dimensions object, sets up the d3 state to draw in the SVG and
+// returns functions to control its state or pre-render the object.
+function renderToSVG($element, parsedDiagram, layout, options = {}) {
+ const {
+ width,
+ height,
+ minSize,
+ initialSize,
+ maxSize,
+ collideRadius,
+ collideExtra,
+ curveOffset,
+ linkDistance,
+ chargeStrength,
+ maxLabelWidth,
+ } = layout;
+ const { animation } = dimensions;
+ const minStateFactor = minSize / initialSize;
+ const maxStateFactor = maxSize / initialSize;
+
+ const sizeFor = (graphNode) =>
+ Math.max(
+ minSize,
+ Math.min(maxSize, initialSize * parsedDiagram.state[graphNode.id]),
);
- const 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");
- element.setAttribute("href", url);
- element.setAttribute("download", filename);
- element.style.display = "none";
- document.body.appendChild(element);
- element.click();
- document.body.removeChild(element);
- URL.revokeObjectURL(url);
- },
+ const scaleFactor = (graphNode) => sizeFor(graphNode) / initialSize;
+ const radiusFor = (graphNode) =>
+ graphNode.maxLineLength * collideRadius * scaleFactor(graphNode) +
+ collideExtra;
- /**
- * Replaces the image in a dialog with the new image data.
- */
- replaceImage(window, imageData) {
- try {
- const $container = window.document.querySelector("main");
- $container.innerHTML = imageData;
- } catch (error) {
- console.error(
- "causalloop: Could not replace image. The DOM wasn't ready!",
- error,
- );
- }
- },
+ const { graphNodes, graphLinks } = buildGraphData(
+ parsedDiagram,
+ maxLabelWidth,
+ );
- // SVG Helpers /////////////////////////////////////////////////////////////
+ const svg = d3.select($element).attr("viewBox", [0, 0, width, height]);
+ svg.selectAll("*").remove();
- /**
- * Creates an SVG element
- */
- createSVG() {
- return document.createElementNS("http://www.w3.org/2000/svg", "svg");
- },
+ drawMarkers(svg);
+ const edge = drawEdges(svg, graphLinks, layout);
+ const edgeLabel = drawEdgeLabels(svg, graphLinks, layout);
+ const node = drawNodes(svg, graphNodes, sizeFor, layout);
+ const dotGroup = svg.append("g").attr("class", "flow-dots");
- /**
- * 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,
+ edge.each(function (link) {
+ link.pathElement = this;
+ });
+
+ const ticked = () => {
+ const padding = 40;
+ graphNodes.forEach((graphNode) => {
+ graphNode.x = Math.max(
+ padding,
+ Math.min(width - padding, graphNode.x),
+ );
+ graphNode.y = Math.max(
+ padding,
+ Math.min(height - padding, graphNode.y),
+ );
+ });
+ edge.attr("d", (link) =>
+ computeEdgePath(link, scaleFactor, collideRadius, curveOffset),
);
- settle();
- return svg;
- },
+ edgeLabel
+ .attr("x", (link) => labelPosition(link, curveOffset).x)
+ .attr("y", (link) => labelPosition(link, curveOffset).y);
+ node.attr("x", (graphNode) => graphNode.x)
+ .attr("y", (graphNode) => graphNode.y)
+ .each(function (graphNode) {
+ d3.select(this).selectAll("tspan").attr("x", graphNode.x);
+ });
+ };
- // Event Handlers //////////////////////////////////////////////////////////
+ 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);
- // 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 });
- }
- },
+ const updateSizes = () => {
+ node.attr("font-size", (graphNode) => `${sizeFor(graphNode)}px`);
+ simulation.force("collide").radius(radiusFor);
+ simulation.alpha(0.2).restart();
+ options.onChange?.();
+ };
- // 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);
- },
+ const animator = createFlowAnimator({
+ parsedDiagram,
+ graphLinks,
+ dotGroup,
+ animation,
+ minStateFactor,
+ maxStateFactor,
+ onSizeChange: updateSizes,
+ });
- // 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",
- );
+ return {
+ update() {
+ simulation.alpha(0.3).restart();
+ },
+ settle() {
+ simulation.tick(300);
+ simulation.stop();
+ ticked();
+ },
+ ...animator,
+ };
+}
- if (!dialog || dialog.closed) {
- console.error("causalloop: Failed to open dialog.");
- return;
- }
+////////////////////////////////////////////////////////////////////////////////
+// SVG Export //////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
- const imageData = internals.renderStandaloneSVG(item).outerHTML;
- internals.onDialogReady(dialog, () =>
- internals.replaceImage(dialog, imageData),
+// Creates an SVG element with an explicit xmlns attribute so outerHTML
+// serializes cleanly for download/zoom.
+function createSVG() {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+ return svg;
+}
+
+// Creates a pre-rendered SVG by settling the animation.
+function renderStandaloneSVG(item) {
+ const svg = createSVG();
+ const { settle } = renderToSVG(svg, parse(item.text), dimensions.full);
+ settle();
+ return svg;
+}
+
+// Starts a download of an SVG text blob with the given filename.
+function download(filename, text) {
+ const blob = new Blob([text], { type: "image/svg+xml" });
+ const url = URL.createObjectURL(blob);
+ const element = document.createElement("a");
+ element.setAttribute("href", url);
+ element.setAttribute("download", filename);
+ element.style.display = "none";
+ document.body.appendChild(element);
+ element.click();
+ document.body.removeChild(element);
+ URL.revokeObjectURL(url);
+}
+
+// Replaces the image in a dialog with the new image data.
+function replaceImage(dialogWindow, imageData) {
+ try {
+ const $container = dialogWindow.document.querySelector("main");
+ $container.innerHTML = imageData;
+ } catch (error) {
+ console.error(
+ "causalloop: Could not replace image. The DOM wasn't ready!",
+ error,
);
- },
+ }
+}
- // 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");
- }
- },
+////////////////////////////////////////////////////////////////////////////////
+// UI Helpers //////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
- // Triggered when the step button is clicked.
- // Moves the animation one iteration.
- onStep(renderer) {
- renderer?.step();
- },
+// Returns HTML that shows a message in the wiki.
+function message(text) {
+ return `
+ <div class="viewer" data-item="viewer" style="width:98%">
+ <div style="width:80%; padding:8px; color:gray; background-color:#eee; margin:0 auto; text-align:center">
+ <i>${text}</i>
+ </div>
+ </div>`;
+}
- // 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);
- },
-};
+// HTML for the thumbnail container and its action bar. Built once at
+// module load so the icons are interpolated once.
+const thumbnailHTML = `
+ <article class="causalloop thumbnail"></article>
+ <nav class="actions">
+ <menu>
+ <li><a href="#" data-action="reset" title="Reset"><img width="18" height="18" alt="reset" src='${icons.reset}'></a></li>
+ <li><a href="#" data-action="play" title="Play/Pause"><img width="18" height="18" alt="play/pause" src='${icons.play}'></a></li>
+ <li><a href="#" data-action="step" title="Step"><img width="18" height="18" alt="step" src='${icons.step}'></a></li>
+ <li><a href="#" data-action="download" title="Download"><img width="18" height="18" alt="download" src='${icons.download}'></a></li>
+ <li><a href="#" data-action="zoom" title="Zoom"><img width="18" height="18" alt="toggle zoom" src='${icons.zoom}'></a></li>
+ </menu>
+ </nav>
+`;
+
+////////////////////////////////////////////////////////////////////////////////
+// Event Handlers //////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
+
+// Runs a callback when the dialog is loaded.
+// readyState is "complete" on the initial empty window, so we must
+// also verify the dialog's href to know the document is really ours.
+function onDialogReady(dialog, callback) {
+ const isReady =
+ dialog.document.readyState === "complete" &&
+ dialog.document.location.href.includes("/plugins/causalloop/dialog/");
+ if (isReady) {
+ callback();
+ } else {
+ dialog.addEventListener("load", callback, { once: true });
+ }
+}
+
+// Triggered when the download button is clicked.
+function onDownload($item, item) {
+ const svg = renderStandaloneSVG(item);
+ const slug = $item.parents(".page").attr("id");
+ download(`${slug}.svg`, svg.outerHTML);
+}
+
+// Triggered when the zoom button is clicked.
+// It opens the dialog and loads the image.
+function onZoom(event, item) {
+ const target = event.shiftKey ? "_blank" : "causalloop";
+ const dialog = window.open(
+ "/plugins/causalloop/dialog/#",
+ target,
+ "popup,height=600,width=800",
+ );
+
+ if (!dialog || dialog.closed) {
+ console.error("causalloop: Failed to open dialog.");
+ return;
+ }
+
+ const imageData = renderStandaloneSVG(item).outerHTML;
+ onDialogReady(dialog, () => replaceImage(dialog, imageData));
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Plugin Lifecycle ////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////////
/**
* Runs once for each block. Do setup for the block before rendering.
*/
const emit = function emit($item) {
- const hasStyleSheet = [...document.styleSheets].some((styleSheet) =>
- styleSheet.href?.endsWith(internals.styleSheet),
+ const hasStyleSheet = [...document.styleSheets].some((sheet) =>
+ sheet.href?.endsWith(styleSheet),
);
if (!hasStyleSheet) {
console.log("Adding causalloop stylesheet.");
const link = document.createElement("link");
link.rel = "stylesheet";
- link.href = internals.styleSheet;
+ link.href = styleSheet;
link.type = "text/css";
document.getElementsByTagName("head")[0].appendChild(link);
}
- return $item.append(internals.message("Loading Causal Loop Diagram"));
+ return $item.append(message("Loading Causal Loop Diagram"));
};
/**
@@ -769,7 +696,26 @@ const bind = async function bind($item, item) {
let renderer = null;
$item.on("dblclick", () => wiki.textEditor($item, item));
- $item.on("click", "a", async (event) => {
+ // Single source of truth for the action bar DOM. Reads live state off
+ // the renderer and drives the play/pause icon and step/reset visibility.
+ const syncActions = () => {
+ if (!renderer) return;
+ const playing = renderer.isPlaying();
+ const dirty = renderer.isDirty();
+ $item
+ .find('a[data-action="play"] img')
+ .attr("src", playing ? icons.pause : icons.play);
+ $item
+ .find('a[data-action="step"]')
+ .parent()
+ .css("display", playing ? "none" : "");
+ $item
+ .find('a[data-action="reset"]')
+ .parent()
+ .css("display", dirty ? "" : "none");
+ };
+
+ $item.on("click", "a", (event) => {
const action = event.currentTarget.dataset?.action;
if (!action) return;
event.stopPropagation();
@@ -777,52 +723,36 @@ const bind = async function bind($item, item) {
switch (action) {
case "download":
- internals.onDownload($item, item);
- break;
+ onDownload($item, item);
+ return;
case "zoom":
- internals.onZoom(event, item);
- break;
+ onZoom(event, item);
+ return;
case "play":
- internals.onPlay(event, renderer, $item);
+ if (renderer?.isPlaying()) renderer.pause();
+ else renderer?.play();
break;
case "step":
- internals.onStep(renderer);
+ renderer?.step();
break;
case "reset":
- internals.onReset(renderer, $item);
+ renderer?.reset();
break;
}
+ syncActions();
});
try {
- const $element = internals.createSVG();
- renderer = internals.renderToSVG(
+ const $element = createSVG();
+ renderer = 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");
- },
- },
+ parse(item.text),
+ dimensions.thumbnail,
+ { onChange: syncActions },
);
- $item.find(".viewer").html(`
- <article class="causalloop thumbnail"></article>
- <nav class="actions">
- <menu>
- <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(".viewer").html(thumbnailHTML);
$item.find(".causalloop").append($element);
+ syncActions();
$item.find("svg text.label").on("click", function (event) {
const title = this.dataset.title;
@@ -834,7 +764,7 @@ const bind = async function bind($item, item) {
});
} catch (error) {
console.log("Failed to parse causal loop diagram: ", error);
- $item.html(internals.message(error.message));
+ $item.html(message(error.message));
}
};