aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/causalloop.js239
1 files changed, 199 insertions, 40 deletions
diff --git a/src/causalloop.js b/src/causalloop.js
index 5267724..afd173e 100644
--- a/src/causalloop.js
+++ b/src/causalloop.js
@@ -4,6 +4,12 @@ import DOMPurify from "isomorphic-dompurify";
const internals = {
kStyleSheet: "/plugins/causalloop/causalloop.css",
+ kTheme: {
+ positive: '#23C17C',
+ negative: '#FA2B00',
+ node: '#0F261F',
+ halo: '#FFFFFF',
+ },
kDimensions: {
full: {
width: 806,
@@ -12,6 +18,17 @@ const internals = {
initialSize: 32,
maxSize: 64,
growthFactor: 0.2,
+ collideRadius: 2,
+ collideExtra: 25,
+ haloWidth: 4,
+ edgeLabelHaloWidth: 3,
+ edgeWidth: 1.5,
+ curveOffset: 20,
+ linkDistance: 180,
+ chargeStrength: -700,
+ maxLabelWidth: 10,
+ nodeFontSize: '14px',
+ edgeFontSize: '11px',
},
thumbnail: {
width: 403,
@@ -20,9 +37,20 @@ const internals = {
initialSize: 16,
maxSize: 32,
growthFactor: 0.1,
+ collideRadius: 2,
+ collideExtra: 10,
+ haloWidth: 3,
+ edgeLabelHaloWidth: 2,
+ edgeWidth: 1,
+ curveOffset: 12,
+ linkDistance: 80,
+ chargeStrength: -300,
+ maxLabelWidth: 10,
+ nodeFontSize: '12px',
+ edgeFontSize: '10px',
}
},
- kLineRe: /([^\s].+?)([+-])>\s*([^\s].+?)(?:\[((?:\d+(?:\.\d+)?)|(?:\.\d+))\])?$/,
+ kLineRe: /([^\s].*?)([+-])>\s*([^\s].*?)(?:\[((?:\d+(?:\.\d+)?)|(?:\.\d+))\])?$/,
/**
* Parses the causal loop diagram line by line and generates an object
@@ -71,53 +99,181 @@ const internals = {
state
}
},
- renderToSVG($element, parsedDiagram, dimensions) {
- const nodes = [...parsedDiagram.nodes].map(id => ({ id }));
- const sizeFor = (id) => (Math.max(
- dimensions.minSize,
- Math.min(
- dimensions.maxSize,
- dimensions.initialSize +
- parsedDiagram.state[id] * dimensions.growthFactor
- )
- ));
+ renderToSVG($element, parsedDiagram, { width, height, collideRadius, collideExtra,
+ haloWidth, edgeLabelHaloWidth, edgeWidth, curveOffset, linkDistance,
+ chargeStrength, maxLabelWidth, nodeFontSize, edgeFontSize }) {
+
+ const nodeId = (node) => typeof node === 'object' ? node.id : node;
+ const { kTheme } = internals;
+ const colorFor = (datum) => datum.value > 0 ? kTheme.positive : kTheme.negative;
+
+ function wrapLabel(text) {
+ if (text.length <= maxLabelWidth) return [text];
+ const words = text.split(/\s+/);
+ const lines = [];
+ let currentLine = words[0];
+ for (let i = 1; i < words.length; i++) {
+ if (currentLine.length + 1 + words[i].length <= maxLabelWidth) {
+ currentLine += ' ' + words[i];
+ } else {
+ lines.push(currentLine);
+ currentLine = words[i];
+ }
+ }
+ lines.push(currentLine);
+ return lines;
+ }
+
+ const nodes = [...parsedDiagram.nodes].map(id => {
+ const lines = wrapLabel(id);
+ const maxLineLength = Math.max(...lines.map(line => line.length));
+ return { id, lines, maxLineLength };
+ });
const links = Object.entries(parsedDiagram.connections)
.flatMap(([source, links]) =>
links.map(({ node: target, value }) => ({ source, target, value })));
- const svg = d3.select($element).attr('viewBox', [0, 0, dimensions.width, dimensions.height]);
+
+ // Build a set of bidirectional pairs for curve detection
+ const linkKeys = new Set(links.map(link => `${nodeId(link.source)}\0${nodeId(link.target)}`));
+ const isBidirectional = (link) => linkKeys.has(`${nodeId(link.target)}\0${nodeId(link.source)}`);
+
+ const svg = d3.select($element).attr('viewBox', [0, 0, width, height]);
svg.selectAll('*').remove();
- const link = svg.append('g').attr('stroke', '#999')
- .selectAll('line')
+
+ // Define arrow markers for positive and negative edges
+ const defs = svg.append('defs');
+ for (const [id, color] of [['arrow-positive', kTheme.positive], ['arrow-negative', kTheme.negative]]) {
+ defs.append('marker')
+ .attr('id', id)
+ .attr('viewBox', '0 0 10 6')
+ .attr('refX', 10)
+ .attr('refY', 3)
+ .attr('markerWidth', 10)
+ .attr('markerHeight', 6)
+ .attr('orient', 'auto')
+ .append('path')
+ .attr('d', 'M0,0 L10,3 L0,6 Z')
+ .attr('fill', color);
+ }
+
+ // Edge paths
+ const edgeGroup = svg.append('g').attr('fill', 'none');
+ const edge = edgeGroup.selectAll('path')
+ .data(links)
+ .join('path')
+ .attr('stroke', datum => colorFor(datum))
+ .attr('stroke-width', edgeWidth)
+ .attr('marker-end', datum => datum.value > 0 ? 'url(#arrow-positive)' : 'url(#arrow-negative)');
+
+ // Edge labels
+ const edgeLabelGroup = svg.append('g')
+ .attr('font-family', 'serif')
+ .attr('font-size', edgeFontSize);
+ const edgeLabel = edgeLabelGroup.selectAll('text')
.data(links)
- .join('line');
+ .join('text')
+ .attr('text-anchor', 'middle')
+ .attr('dy', '-4')
+ .attr('fill', datum => colorFor(datum))
+ .attr('stroke', kTheme.halo)
+ .attr('stroke-width', edgeLabelHaloWidth)
+ .attr('paint-order', 'stroke')
+ .text(datum => datum.value > 0 ? 'S(+)' : 'O(-)');
+
+ // Node labels — long labels wrap into multiple <tspan> lines
+ const lineHeight = 1.2;
const node = svg.append('g')
- .selectAll('g')
+ .attr('font-family', 'serif')
+ .attr('font-size', nodeFontSize)
+ .selectAll('text')
.data(nodes)
- .join('g');
- const circle = node
- .append('circle')
- .attr('fill', '#fff')
- .attr('stroke', '#333');
- node
- .append('text')
+ .join('text')
.attr('text-anchor', 'middle')
- .attr('dy', '.35em')
- .text(d => d.id);
+ .attr('fill', kTheme.node)
+ .attr('stroke', kTheme.halo)
+ .attr('stroke-width', haloWidth)
+ .attr('paint-order', 'stroke')
+ .attr('class', 'label')
+ .attr('data-title', datum => datum.id)
+ .each(function(datum) {
+ const element = d3.select(this);
+ const verticalOffset = 0.35 - (datum.lines.length - 1) * lineHeight / 2;
+ datum.lines.forEach((line, i) => {
+ element.append('tspan')
+ .attr('dy', i === 0 ? `${verticalOffset}em` : `${lineHeight}em`)
+ .text(line);
+ });
+ });
const simulation = forceSimulation(nodes)
- .force('link', forceLink(links).id(d => d.id).distance(120))
- .force('charge', forceManyBody().strength(-400))
- .force('center', forceCenter(dimensions.width / 2, dimensions.height / 2))
- .force('collide', forceCollide().radius(d => sizeFor(d.id) + 4))
- .on('tick', () => {
- circle.attr('r', d => sizeFor(d.id));
- link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
- .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
- node.attr('transform', d => `translate(${d.x},${d.y})`);
+ .force('link', forceLink(links).id(datum => datum.id).distance(linkDistance))
+ .force('charge', forceManyBody().strength(chargeStrength))
+ .force('center', forceCenter(width / 2, height / 2))
+ .force('collide', forceCollide().radius(datum => datum.maxLineLength * collideRadius + collideExtra))
+ .on('tick', ticked);
+
+ function labelPosition(datum) {
+ const midX = (datum.source.x + datum.target.x) / 2;
+ const midY = (datum.source.y + datum.target.y) / 2;
+ if (!isBidirectional(datum)) return { x: midX, y: midY };
+ const dx = datum.target.x - datum.source.x;
+ const dy = datum.target.y - datum.source.y;
+ const length = Math.sqrt(dx * dx + dy * dy) || 1;
+ return {
+ x: midX + -(dy / length) * curveOffset,
+ y: midY + (dx / length) * curveOffset
+ };
+ }
+
+ function ticked() {
+ // Clamp nodes inside the viewBox
+ const padding = 40;
+ nodes.forEach(datum => {
+ datum.x = Math.max(padding, Math.min(width - padding, datum.x));
+ datum.y = Math.max(padding, Math.min(height - padding, datum.y));
});
+ edge.attr('d', datum => {
+ const sourceX = datum.source.x, sourceY = datum.source.y;
+ const targetX = datum.target.x, targetY = datum.target.y;
+ const dx = targetX - sourceX, dy = targetY - sourceY;
+ const length = Math.sqrt(dx * dx + dy * dy) || 1;
+
+ // Shorten endpoints based on approach angle — text is wide but short
+ const unitX = dx / length, unitY = dy / length;
+ const absUX = Math.abs(unitX), absUY = Math.abs(unitY);
+ const sourceHalfWidth = (datum.source.maxLineLength || 4) * collideRadius;
+ const targetHalfWidth = (datum.target.maxLineLength || 4) * collideRadius;
+ const sourceHalfHeight = 8 * (datum.source.lines?.length || 1);
+ const targetHalfHeight = 8 * (datum.target.lines?.length || 1);
+ const sourceMargin = absUX * sourceHalfWidth + absUY * sourceHalfHeight + 4;
+ const targetMargin = absUX * targetHalfWidth + absUY * targetHalfHeight + 8;
+ const x1 = sourceX + unitX * sourceMargin, y1 = sourceY + unitY * sourceMargin;
+ const x2 = targetX - unitX * targetMargin, y2 = targetY - unitY * targetMargin;
+
+ if (isBidirectional(datum)) {
+ const perpX = -unitY * curveOffset, perpY = unitX * curveOffset;
+ const controlX = (x1 + x2) / 2 + perpX, controlY = (y1 + y2) / 2 + perpY;
+ return `M${x1},${y1} Q${controlX},${controlY} ${x2},${y2}`;
+ }
+ return `M${x1},${y1} L${x2},${y2}`;
+ });
+
+ edgeLabel.each(function(datum) {
+ const position = labelPosition(datum);
+ d3.select(this).attr('x', position.x).attr('y', position.y);
+ });
+
+ node.each(function(datum) {
+ const element = d3.select(this);
+ element.attr('x', datum.x).attr('y', datum.y);
+ element.selectAll('tspan').attr('x', datum.x);
+ });
+ }
+
return {
- update() { simulation.alpha(0.3).restart(); }
+ update() { simulation.alpha(0.3).restart(); },
+ settle() { simulation.tick(300); simulation.stop(); ticked(); }
};
},
@@ -210,13 +366,14 @@ const bind = async function bind($item, item) {
case "download":
{
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- const imageData = internals.renderToSVG(
+ const { settle } = internals.renderToSVG(
svg,
internals.parse(item.text),
internals.kDimensions.full,
);
+ settle();
const slug = $item.parents(".page").attr("id");
- internals.download(`${slug}.svg`, imageData);
+ internals.download(`${slug}.svg`, svg.outerHTML);
}
break;
case "zoom":
@@ -235,11 +392,13 @@ const bind = async function bind($item, item) {
}
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- const imageData = internals.renderToSVG(
+ const { settle } = internals.renderToSVG(
svg,
internals.parse(item.text),
internals.kDimensions.full,
);
+ settle();
+ const imageData = svg.outerHTML;
// We *MUST* check both readyState and href, because
// readyState will be complete when it first loads,
@@ -283,8 +442,8 @@ const bind = async function bind($item, item) {
`);
$item.find(".causalloop").append($element);
- $item.find('svg text:gt(7)').on('click', function(event) {
- const title = this.innerHTML;
+ $item.find('svg text.label').on('click', function(event) {
+ const title = this.dataset.title;
let $page = $item.parents('.page')
wiki.pageHandler.context = wiki.lineup.atKey($page.data('key')).getContext()
wiki.doInternalLink(title, event.shiftKey ? null : $page)