diff options
| -rw-r--r-- | src/causalloop.js | 499 |
1 files changed, 286 insertions, 213 deletions
diff --git a/src/causalloop.js b/src/causalloop.js index e13b78a..80360fa 100644 --- a/src/causalloop.js +++ b/src/causalloop.js @@ -107,6 +107,235 @@ const internals = { }, /** + * Wraps a label string into multiple lines so no line exceeds + * maxLabelWidth characters. + */ + wrapLabel(text, maxLabelWidth) { + if (text.length <= maxLabelWidth) return [text]; + const words = text.split(/\s+/); + const lines = []; + let currentLine = words[0]; + for (let index = 1; index < words.length; index++) { + if (currentLine.length + 1 + words[index].length <= maxLabelWidth) { + currentLine += " " + words[index]; + } else { + lines.push(currentLine); + currentLine = words[index]; + } + } + lines.push(currentLine); + return lines; + }, + + /** + * Builds the d3 graph data (nodes, links, and a bidirectional predicate) + * from a parsed diagram. + */ + buildGraphData(parsedDiagram, maxLabelWidth) { + const graphNodes = [...parsedDiagram.nodes].map((id) => { + const lines = internals.wrapLabel(id, maxLabelWidth); + const maxLineLength = Math.max(...lines.map((line) => line.length)); + return { id, lines, maxLineLength }; + }); + const graphLinks = Object.entries(parsedDiagram.connections).flatMap( + ([source, outgoing]) => + outgoing.map(({ target, weight }) => ({ + source, + target, + weight, + })), + ); + const linkKeys = new Set( + graphLinks.map((link) => `${link.source}\0${link.target}`), + ); + const isBidirectional = (link) => + linkKeys.has(`${link.target.id}\0${link.source.id}`); + return { graphNodes, graphLinks, isBidirectional }; + }, + + /** + * Appends the positive/negative arrow markers to the SVG's <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); + }, + + /** + * Draws the edges group and returns the path selection. + */ + drawEdges(svg, graphLinks, theme, edgeWidth) { + return svg + .append("g") + .attr("fill", "none") + .selectAll("path") + .data(graphLinks) + .join("path") + .attr("stroke", (link) => + link.weight > 0 ? theme.positive : theme.negative, + ) + .attr("stroke-width", edgeWidth) + .attr("marker-end", (link) => + link.weight > 0 + ? "url(#arrow-positive)" + : "url(#arrow-negative)", + ); + }, + + /** + * Draws the edge-label group and returns the text selection. + */ + drawEdgeLabels(svg, graphLinks, theme, edgeFontSize, edgeLabelHaloWidth) { + return svg + .append("g") + .attr("font-family", "serif") + .attr("font-size", edgeFontSize) + .selectAll("text") + .data(graphLinks) + .join("text") + .attr("text-anchor", "middle") + .attr("dy", "-4") + .attr("fill", (link) => + link.weight > 0 ? theme.positive : theme.negative, + ) + .attr("stroke", theme.halo) + .attr("stroke-width", edgeLabelHaloWidth) + .attr("paint-order", "stroke") + .text((link) => (link.weight > 0 ? "S(+)" : "O(-)")); + }, + + /** + * Draws the node-label group (with wrapped tspans) and returns the text + * selection. sizeFor is a callback because font-size tracks live state. + */ + drawNodes(svg, graphNodes, theme, sizeFor, haloWidth) { + const lineHeight = 1.2; + return svg + .append("g") + .attr("font-family", "serif") + .selectAll("text") + .data(graphNodes) + .join("text") + .attr("text-anchor", "middle") + .attr("fill", theme.node) + .attr("stroke", theme.halo) + .attr("stroke-width", haloWidth) + .attr("paint-order", "stroke") + .attr("font-size", (graphNode) => `${sizeFor(graphNode)}px`) + .attr("class", "label") + .attr("data-title", (graphNode) => graphNode.id) + .each(function (graphNode) { + const element = d3.select(this); + const verticalOffset = + 0.35 - ((graphNode.lines.length - 1) * 1.2) / 2; + graphNode.lines.forEach((line, index) => { + element + .append("tspan") + .attr( + "dy", + index === 0 + ? `${verticalOffset}em` + : `${lineHeight}em`, + ) + .text(line); + }); + }); + }, + + /** + * Computes the SVG path string for a causal link, inset by each node's + * halo and curved outward for bidirectional links. + */ + computeEdgePath( + link, + isBidirectional, + scaleFactor, + collideRadius, + curveOffset, + ) { + const nodeMargin = ( + graphNode, + absoluteUnitX, + absoluteUnitY, + extraPadding, + ) => { + const factor = scaleFactor(graphNode); + const halfWidth = graphNode.maxLineLength * collideRadius * factor; + const halfHeight = 8 * graphNode.lines.length * factor; + return ( + absoluteUnitX * halfWidth + + absoluteUnitY * halfHeight + + extraPadding + ); + }; + const { source, target } = link; + const deltaX = target.x - source.x; + const deltaY = target.y - source.y; + const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const unitX = deltaX / length; + const unitY = deltaY / length; + const absoluteUnitX = Math.abs(unitX); + const absoluteUnitY = Math.abs(unitY); + const sourceMargin = nodeMargin( + source, + absoluteUnitX, + absoluteUnitY, + 4, + ); + const targetMargin = nodeMargin( + target, + absoluteUnitX, + absoluteUnitY, + 8, + ); + const startX = source.x + unitX * sourceMargin; + const startY = source.y + unitY * sourceMargin; + const endX = target.x - unitX * targetMargin; + const endY = target.y - unitY * targetMargin; + + if (!isBidirectional(link)) { + return `M${startX},${startY} L${endX},${endY}`; + } + const perpendicularX = -unitY * curveOffset; + const perpendicularY = unitX * curveOffset; + const controlX = (startX + endX) / 2 + perpendicularX; + const controlY = (startY + endY) / 2 + perpendicularY; + return `M${startX},${startY} Q${controlX},${controlY} ${endX},${endY}`; + }, + + /** + * Returns the xy position for an edge label, offset outward for + * bidirectional links so labels don't overlap the curves. + */ + labelPosition(link, isBidirectional, curveOffset) { + const midpointX = (link.source.x + link.target.x) / 2; + const midpointY = (link.source.y + link.target.y) / 2; + if (!isBidirectional(link)) return { x: midpointX, y: midpointY }; + const deltaX = link.target.x - link.source.x; + const deltaY = link.target.y - link.source.y; + const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + return { + x: midpointX + -(deltaY / length) * curveOffset, + y: midpointY + (deltaX / length) * curveOffset, + }; + }, + + /** * Given an existing SVG element, a parsed diagram (see #parse()), and a * dimensions object, sets up the d3 state to draw in the SVG and returns * functions to control its state or pre-render the object. @@ -137,8 +366,6 @@ const 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, @@ -152,141 +379,34 @@ const internals = { 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 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 = [...parsedDiagram.nodes].map((id) => { - const lines = wrapLabel(id); - 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}`); + const { graphNodes, graphLinks, isBidirectional } = + internals.buildGraphData(parsedDiagram, maxLabelWidth); const svg = d3.select($element).attr("viewBox", [0, 0, width, height]); svg.selectAll("*").remove(); - drawMarkers(); - const edge = drawEdges(); - const edgeLabel = drawEdgeLabels(); - const node = drawNodes(); + internals.drawMarkers(svg, theme); + const edge = internals.drawEdges(svg, graphLinks, theme, edgeWidth); + const edgeLabel = internals.drawEdgeLabels( + svg, + graphLinks, + theme, + edgeFontSize, + edgeLabelHaloWidth, + ); + const node = internals.drawNodes( + svg, + graphNodes, + theme, + sizeFor, + haloWidth, + ); const dotGroup = svg.append("g").attr("class", "flow-dots"); - 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); - } - - 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)", - ); - } - - 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(-)")); - } - - 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 pathByKey = new Map(); + edge.each(function (link) { + pathByKey.set(`${link.source}\0${link.target}`, this); + }); const simulation = forceSimulation(graphNodes) .force( @@ -325,13 +445,7 @@ const internals = { } function animateDot(sourceId, targetId, delta) { - const pathElement = edge - .filter( - (link) => - link.source.id === sourceId && - link.target.id === targetId, - ) - .node(); + const pathElement = pathByKey.get(`${sourceId}\0${targetId}`); if (!pathElement) return; const length = pathElement.getTotalLength(); if (!length) return; @@ -359,6 +473,7 @@ const internals = { 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); @@ -370,6 +485,7 @@ const internals = { 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, @@ -380,71 +496,6 @@ const internals = { 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: midpointX + -(deltaY / length) * curveOffset, - y: midpointY + (deltaX / length) * curveOffset, - }; - } - function ticked() { const padding = 40; graphNodes.forEach((graphNode) => { @@ -458,17 +509,39 @@ const internals = { ); }); - 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, + edge.attr("d", (link) => + internals.computeEdgePath( + link, + isBidirectional, + scaleFactor, + collideRadius, + curveOffset, + ), ); - node.selectAll("tspan").attr("x", function () { - return d3.select(this.parentNode).datum().x; - }); + edgeLabel + .attr( + "x", + (link) => + internals.labelPosition( + link, + isBidirectional, + curveOffset, + ).x, + ) + .attr( + "y", + (link) => + internals.labelPosition( + link, + isBidirectional, + curveOffset, + ).y, + ); + node.attr("x", (graphNode) => graphNode.x) + .attr("y", (graphNode) => graphNode.y) + .each(function (graphNode) { + d3.select(this).selectAll("tspan").attr("x", graphNode.x); + }); } function pause() { |