diff options
Diffstat (limited to 'src/causalloop.js')
| -rw-r--r-- | src/causalloop.js | 160 |
1 files changed, 152 insertions, 8 deletions
diff --git a/src/causalloop.js b/src/causalloop.js index 79d803c..bfaa2a6 100644 --- a/src/causalloop.js +++ b/src/causalloop.js @@ -82,16 +82,50 @@ const lineRegex = // Parsing ///////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// +// Walks the lineup of items preceding $item and collects parsed diagrams +// from every .causalloop-source it finds. Returns [] outside the wiki +// (no lineup to walk). +function lineup($item) { + if (typeof wiki === "undefined") return []; + const results = []; + const candidates = $(".item:lt(" + $(".item").index($item) + ")"); + const sources = candidates.filter(".causalloop-source"); + for (const source of sources) { + results.push(source.causalloopData()); + } + return results; +} + // 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) { +// +// A bare "LINEUP" line pulls in nodes and connections from every +// .causalloop-source item earlier in the lineup. Local connections win +// over upstream ones with the same (source, target) key. +function parse(text, $item) { const nodes = new Set(); const connections = {}; const seenConnections = new Set(); for (const rawLine of text.split("\n")) { + if (rawLine.trim() === "LINEUP") { + for (const upstream of lineup($item)) { + for (const id of upstream.nodes) nodes.add(id); + for (const [source, outgoing] of Object.entries( + upstream.connections, + )) { + for (const conn of outgoing) { + const key = `${source}\0${conn.target}`; + if (seenConnections.has(key)) continue; + seenConnections.add(key); + (connections[source] ??= []).push({ ...conn }); + } + } + } + continue; + } const matches = rawLine.match(lineRegex); if (matches === null) continue; @@ -300,6 +334,104 @@ function labelPosition(link, curveOffset) { } //////////////////////////////////////////////////////////////////////////////// +// Sourcing //////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +// Finds the name for this diagram by walking the DOM to the nearest +// preceding heading on the same page. When multiple .causalloop blocks +// share that heading, suffixes " #2", " #3", … in document order. +function findName($element) { + const page = $element.closest(".page"); + if (!page) return "Unknown"; + + const headings = page.querySelectorAll("h1, h2, h3, h4, h5, h6"); + let closestHeading = null; + for (const heading of headings) { + const elementFollowsHeading = + heading.compareDocumentPosition($element) & + Node.DOCUMENT_POSITION_FOLLOWING; + if (elementFollowsHeading) closestHeading = heading; + } + if (!closestHeading) return "Unknown"; + + const baseName = closestHeading.textContent.trim(); + const causalloops = page.querySelectorAll(".causalloop.item"); + let position = 0; + for (const causalloop of causalloops) { + const causalloopFollowsHeading = + closestHeading.compareDocumentPosition(causalloop) & + Node.DOCUMENT_POSITION_FOLLOWING; + if (!causalloopFollowsHeading) continue; + position++; + if (causalloop === $element) break; + } + return position <= 1 ? baseName : `${baseName} #${position}`; +} + +// Creates the aspect data type out of the existing text. +// tAspectData := { +// +name: String, +// +graph: tAspectGraph +// } +// tAspectGraph := { +// nodes: Array<tAspectNode>, +// rels: Array<tAspectRelation> +// } +// tAspectNode := { +// type: String, +// in: Array<Number>, +// out: Array<Number>, +// props: Object<String, Any> +// } +// tAspectRelation := { +// type: String, +// from: Number, +// to: Number, +// props: Object<String, Any> +// } +function aspectData($element, text) { + const $item = $($element); + const { nodes, connections } = parse(text, $item); + const nodeIds = [...nodes]; + const indexById = new Map(nodeIds.map((id, index) => [id, index])); + + const aspectNodes = nodeIds.map((id) => ({ + type: "variable", + in: [], + out: [], + props: { name: id }, + })); + + const rels = []; + for (const [source, outgoing] of Object.entries(connections)) { + for (const { weight, target } of outgoing) { + const from = indexById.get(source); + const to = indexById.get(target); + const relIndex = rels.length; + rels.push({ + type: weight > 0 ? "reinforces" : "balances", + from, + to, + props: { magnitude: Math.abs(weight) }, + }); + aspectNodes[from].out.push(relIndex); + aspectNodes[to].in.push(relIndex); + } + } + + return [{ + name: findName($element), + graph: { nodes: aspectNodes, rels }, + }]; +} + +// Returns this diagram's nodes + connections for upstream consumers. +// LINEUP inside item.text expands recursively via parse(). +function causalloopData(item, $item) { + return parse(item.text, $item); +} + +//////////////////////////////////////////////////////////////////////////////// // Animation /////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// @@ -554,9 +686,13 @@ function createSVG() { } // Creates a pre-rendered SVG by settling the animation. -function renderStandaloneSVG(item) { +function renderStandaloneSVG($item, item) { const svg = createSVG(); - const { settle } = renderToSVG(svg, parse(item.text), dimensions.full); + const { settle } = renderToSVG( + svg, + parse(item.text, $item), + dimensions.full, + ); settle(); return svg; } @@ -637,14 +773,14 @@ function onDialogReady(dialog, callback) { // Triggered when the download button is clicked. function onDownload($item, item) { - const svg = renderStandaloneSVG(item); + const svg = renderStandaloneSVG($item, 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) { +function onZoom(event, $item, item) { const target = event.shiftKey ? "_blank" : "causalloop"; const dialog = window.open( "/plugins/causalloop/dialog/#", @@ -657,7 +793,7 @@ function onZoom(event, item) { return; } - const imageData = renderStandaloneSVG(item).outerHTML; + const imageData = renderStandaloneSVG($item, item).outerHTML; onDialogReady(dialog, () => replaceImage(dialog, imageData)); } @@ -726,7 +862,7 @@ const bind = async function bind($item, item) { onDownload($item, item); return; case "zoom": - onZoom(event, item); + onZoom(event, $item, item); return; case "play": if (renderer?.isPlaying()) renderer.pause(); @@ -743,10 +879,18 @@ const bind = async function bind($item, item) { }); try { + // Register the source hooks before rendering so any sibling + // binding concurrently can resolve this item via lineup(). + const root = $item.get(0); + root.classList.add('aspect-source'); + root.classList.add('causalloop-source'); + root.aspectData = () => aspectData(root, item.text); + root.causalloopData = () => causalloopData(item, $item); + const $element = createSVG(); renderer = renderToSVG( $element, - parse(item.text), + parse(item.text, $item), dimensions.thumbnail, { onChange: syncActions }, ); |