aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/causalloop.js160
2 files changed, 153 insertions, 9 deletions
diff --git a/package.json b/package.json
index 4171bef..d8816a8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "wiki-plugin-causalloop",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "Federated Wiki - Causal Loop Diagram Plugin",
"keywords": [
"systems",
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 },
);