aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/causalloop.js499
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() {