diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/causalloop.js | 1350 |
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)); } }; |