diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-04-14 12:37:36 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-04-14 17:26:45 +0200 |
| commit | 37fa67b9ad1f8a6183fa4a8308c01ec5449b4730 (patch) | |
| tree | 11a06b6523a591dba5a3af5e190514f04c1848d9 | |
| parent | de62a5da0225b79fcf2460727bb7d765f8be2426 (diff) | |
Style the diagram
| -rw-r--r-- | client/causalloop.css | 2 | ||||
| -rw-r--r-- | src/causalloop.js | 239 |
2 files changed, 200 insertions, 41 deletions
diff --git a/client/causalloop.css b/client/causalloop.css index fbd6660..78875d1 100644 --- a/client/causalloop.css +++ b/client/causalloop.css @@ -26,6 +26,6 @@ width: 100%; } -.causalloop.thumbnail text:nth-of-type(n + 7) { +.causalloop.thumbnail text.label { cursor: pointer; } 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) |