@neoloopy/cld-canvas 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +329 -153
- package/dist/engine/engine.d.ts +10 -0
- package/dist/engine/nativeEngine.d.ts +2 -0
- package/dist/engine/nativeEngine.js +33 -14
- package/dist/engine/noteCodec.js +11 -3
- package/dist/engine/publicInterface.d.ts +43 -0
- package/dist/engine/publicInterface.js +52 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
Framework-agnostic causal loop diagram (CLD) canvas renderer and in-memory edit
|
|
4
4
|
engine used by neoloopy surfaces, including the Obsidian plugin and web app.
|
|
5
|
+
Pure TypeScript, ESM-only, with no runtime dependency on Obsidian, React, Vue,
|
|
6
|
+
or a server.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
Obsidian, React, Vue, or a server. It gives you two pieces:
|
|
8
|
+

|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
<sub>The canvas this package renders — an SIR epidemic model open in Obsidian with the link-editing toolbar.</sub>
|
|
11
|
+
|
|
12
|
+
**This README is ordered easy → hard** — start at the top with the one-tag React
|
|
13
|
+
drop-in and go only as deep as you need:
|
|
14
|
+
|
|
15
|
+
1. [React, in one tag](#1-react-in-one-tag-easiest) — easiest
|
|
16
|
+
2. [Build and render it yourself](#2-build-and-render-it-yourself) — any framework / full control
|
|
17
|
+
3. [The engine](#3-the-engine) — create, edit, and load models
|
|
18
|
+
4. [Exporting](#4-exporting) and [analysis](#5-analysis-helpers) — advanced
|
|
13
19
|
|
|
14
20
|
## Install
|
|
15
21
|
|
|
@@ -17,30 +23,280 @@ Obsidian, React, Vue, or a server. It gives you two pieces:
|
|
|
17
23
|
npm install @neoloopy/cld-canvas
|
|
18
24
|
```
|
|
19
25
|
|
|
20
|
-
If you use `NativeEngine
|
|
21
|
-
package does not bundle one:
|
|
26
|
+
If you use the engine (`NativeEngine`), also install a YAML parser for note
|
|
27
|
+
frontmatter — the package does not bundle one:
|
|
22
28
|
|
|
23
29
|
```sh
|
|
24
30
|
npm install yaml
|
|
25
31
|
```
|
|
26
32
|
|
|
27
|
-
##
|
|
33
|
+
## 1. React, in one tag (easiest)
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- A `CanvasRenderingContext2D` if you use the painter
|
|
35
|
+
Drop the [`<CldCanvas>` component](#the-cldcanvas-component) below into your
|
|
36
|
+
project, hand it a spec, and you're done:
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
```tsx
|
|
39
|
+
import { CldCanvas } from "./CldCanvas";
|
|
40
|
+
|
|
41
|
+
export default function App() {
|
|
42
|
+
return (
|
|
43
|
+
<CldCanvas
|
|
44
|
+
spec={{
|
|
45
|
+
variables: [
|
|
46
|
+
{ label: "Users", type: "stock" },
|
|
47
|
+
{ label: "Word of mouth" },
|
|
48
|
+
{ label: "Signups" },
|
|
49
|
+
],
|
|
50
|
+
links: [
|
|
51
|
+
{ from: "Users", to: "Word of mouth", polarity: "+" },
|
|
52
|
+
{ from: "Word of mouth", to: "Signups", polarity: "+" },
|
|
53
|
+
{ from: "Signups", to: "Users", polarity: "+" },
|
|
54
|
+
],
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
34
60
|
|
|
35
|
-
|
|
61
|
+
It builds the model, detects the feedback loops, frames the diagram, and gives
|
|
62
|
+
you pan (drag) + zoom (wheel) — with no surrounding CSS required. Props: `spec`
|
|
63
|
+
(required), `theme` (`"light"` | `"dark"` | a custom `Theme`), `interactive`
|
|
64
|
+
(default `true`), `height` (number px or CSS string, default `400`), plus
|
|
65
|
+
`onReady(graph)` / `onError(error)` callbacks.
|
|
66
|
+
|
|
67
|
+
### The `<CldCanvas>` component
|
|
68
|
+
|
|
69
|
+
Copy this one file into your project; its only dependencies are
|
|
70
|
+
`@neoloopy/cld-canvas` and `yaml`.
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
// CldCanvas.tsx — a drop-in React wrapper around @neoloopy/cld-canvas.
|
|
74
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
75
|
+
import type { CSSProperties } from "react";
|
|
36
76
|
import { parse } from "yaml";
|
|
37
77
|
import {
|
|
38
78
|
MemoryStorage,
|
|
39
79
|
NativeEngine,
|
|
80
|
+
Camera,
|
|
81
|
+
SceneCache,
|
|
82
|
+
paint,
|
|
83
|
+
LIGHT,
|
|
84
|
+
DARK,
|
|
40
85
|
} from "@neoloopy/cld-canvas";
|
|
86
|
+
import type { BuildSpec, GraphView, PaintUi, Theme } from "@neoloopy/cld-canvas";
|
|
87
|
+
|
|
88
|
+
export interface CldCanvasProps {
|
|
89
|
+
/** The causal-loop diagram to render: variables + polarized links. */
|
|
90
|
+
spec: BuildSpec;
|
|
91
|
+
/** Color tokens — "light" / "dark", or a custom Theme. Default "light". */
|
|
92
|
+
theme?: "light" | "dark" | Theme;
|
|
93
|
+
/** Pan (drag) + zoom (wheel). Default true. */
|
|
94
|
+
interactive?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Canvas height. A number is pixels (default 400); a string is used verbatim,
|
|
97
|
+
* so pass "100%" / "60vh" to fill a sized parent. Width always fills the
|
|
98
|
+
* parent, so `<CldCanvas spec={...} />` works with no surrounding CSS.
|
|
99
|
+
*/
|
|
100
|
+
height?: number | string;
|
|
101
|
+
className?: string;
|
|
102
|
+
style?: CSSProperties;
|
|
103
|
+
/** Called with the loaded graph (detected loops, badge labels) after a build. */
|
|
104
|
+
onReady?: (graph: GraphView) => void;
|
|
105
|
+
/** Called if the spec is invalid or the model fails to build. */
|
|
106
|
+
onError?: (error: Error) => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Interaction fields the painter wants every frame; we drive only pan/zoom, so
|
|
110
|
+
// the rest stay inert.
|
|
111
|
+
const PAINT_DEFAULTS = {
|
|
112
|
+
selectedNodeId: null,
|
|
113
|
+
selectedEdgeId: null,
|
|
114
|
+
selectedLoopKey: null,
|
|
115
|
+
liveNodeIds: new Set<string>(),
|
|
116
|
+
linkPreview: null,
|
|
117
|
+
connectNodeId: null,
|
|
118
|
+
loopHighlight: null,
|
|
119
|
+
pulsePhase: 0,
|
|
120
|
+
flowPhase: 0,
|
|
121
|
+
} satisfies Omit<PaintUi, "cssWidth" | "cssHeight" | "dpr">;
|
|
122
|
+
|
|
123
|
+
// The full engine path: an in-memory vault, build the model, load the detected
|
|
124
|
+
// graph. NativeEngine needs a YAML parser for note frontmatter (yaml).
|
|
125
|
+
async function buildGraph(spec: BuildSpec): Promise<GraphView> {
|
|
126
|
+
const engine = new NativeEngine(new MemoryStorage(), parse, {
|
|
127
|
+
modelsRoot: "Models",
|
|
128
|
+
});
|
|
129
|
+
const model = await engine.createModel("model");
|
|
130
|
+
await engine.buildModel(model.folder, spec); // layout defaults to true
|
|
131
|
+
return engine.loadGraph(model.folder);
|
|
132
|
+
}
|
|
41
133
|
|
|
42
|
-
|
|
43
|
-
|
|
134
|
+
function resolveTheme(t: CldCanvasProps["theme"]): Theme {
|
|
135
|
+
if (t === "dark") return DARK;
|
|
136
|
+
if (t === "light" || t == null) return LIGHT;
|
|
137
|
+
return t;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function CldCanvas({
|
|
141
|
+
spec,
|
|
142
|
+
theme = "light",
|
|
143
|
+
interactive = true,
|
|
144
|
+
height = 400,
|
|
145
|
+
className,
|
|
146
|
+
style,
|
|
147
|
+
onReady,
|
|
148
|
+
onError,
|
|
149
|
+
}: CldCanvasProps) {
|
|
150
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
151
|
+
const camera = useRef(new Camera()).current;
|
|
152
|
+
const sceneCache = useRef(new SceneCache()).current;
|
|
153
|
+
const graphRef = useRef<GraphView | null>(null);
|
|
154
|
+
const fitted = useRef(false);
|
|
155
|
+
const drag = useRef<{ x: number; y: number } | null>(null);
|
|
156
|
+
|
|
157
|
+
const resolved = useMemo(() => resolveTheme(theme), [theme]);
|
|
158
|
+
|
|
159
|
+
// Mutable mirrors so the build effect and the once-registered listeners read
|
|
160
|
+
// current values without re-subscribing.
|
|
161
|
+
const themeRef = useRef(resolved);
|
|
162
|
+
themeRef.current = resolved;
|
|
163
|
+
const interactiveRef = useRef(interactive);
|
|
164
|
+
interactiveRef.current = interactive;
|
|
165
|
+
const onReadyRef = useRef(onReady);
|
|
166
|
+
onReadyRef.current = onReady;
|
|
167
|
+
const onErrorRef = useRef(onError);
|
|
168
|
+
onErrorRef.current = onError;
|
|
169
|
+
|
|
170
|
+
const render = () => {
|
|
171
|
+
const canvas = canvasRef.current;
|
|
172
|
+
const ctx = canvas?.getContext("2d");
|
|
173
|
+
if (!canvas || !ctx) return;
|
|
174
|
+
|
|
175
|
+
const dpr = window.devicePixelRatio || 1;
|
|
176
|
+
const width = canvas.clientWidth;
|
|
177
|
+
const height = canvas.clientHeight;
|
|
178
|
+
const bw = Math.max(1, Math.floor(width * dpr));
|
|
179
|
+
const bh = Math.max(1, Math.floor(height * dpr));
|
|
180
|
+
if (canvas.width !== bw) canvas.width = bw;
|
|
181
|
+
if (canvas.height !== bh) canvas.height = bh;
|
|
182
|
+
|
|
183
|
+
const scene = sceneCache.build(graphRef.current, new Map(), new Map());
|
|
184
|
+
ctx.clearRect(0, 0, bw, bh);
|
|
185
|
+
if (!scene) return;
|
|
186
|
+
|
|
187
|
+
if (!fitted.current && sceneCache.fit(camera, width, height)) {
|
|
188
|
+
fitted.current = true; // frame once per spec; pan/zoom persists after
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
paint(ctx, scene, camera, themeRef.current, {
|
|
192
|
+
...PAINT_DEFAULTS,
|
|
193
|
+
cssWidth: width,
|
|
194
|
+
cssHeight: height,
|
|
195
|
+
dpr,
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
const renderRef = useRef(render);
|
|
199
|
+
renderRef.current = render;
|
|
200
|
+
|
|
201
|
+
// Rebuild only when the spec's content changes (not on every parent render).
|
|
202
|
+
const specKey = useMemo(() => JSON.stringify(spec), [spec]);
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
let cancelled = false;
|
|
205
|
+
buildGraph(spec)
|
|
206
|
+
.then((graph) => {
|
|
207
|
+
if (cancelled) return;
|
|
208
|
+
graphRef.current = graph;
|
|
209
|
+
fitted.current = false;
|
|
210
|
+
renderRef.current();
|
|
211
|
+
onReadyRef.current?.(graph);
|
|
212
|
+
})
|
|
213
|
+
.catch((e) => {
|
|
214
|
+
if (!cancelled) {
|
|
215
|
+
onErrorRef.current?.(e instanceof Error ? e : new Error(String(e)));
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return () => {
|
|
219
|
+
cancelled = true;
|
|
220
|
+
};
|
|
221
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
222
|
+
}, [specKey]);
|
|
223
|
+
|
|
224
|
+
// Repaint (no refit) when the theme changes.
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
renderRef.current();
|
|
227
|
+
}, [resolved]);
|
|
228
|
+
|
|
229
|
+
// Register resize + wheel once; both read live state via refs.
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const canvas = canvasRef.current;
|
|
232
|
+
if (!canvas) return;
|
|
233
|
+
|
|
234
|
+
const ro = new ResizeObserver(() => renderRef.current());
|
|
235
|
+
ro.observe(canvas);
|
|
236
|
+
|
|
237
|
+
const onWheel = (e: WheelEvent) => {
|
|
238
|
+
if (!interactiveRef.current) return;
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
camera.zoomAt(e.offsetX, e.offsetY, Math.exp(-e.deltaY * 0.0015));
|
|
241
|
+
renderRef.current();
|
|
242
|
+
};
|
|
243
|
+
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
244
|
+
|
|
245
|
+
return () => {
|
|
246
|
+
ro.disconnect();
|
|
247
|
+
canvas.removeEventListener("wheel", onWheel);
|
|
248
|
+
};
|
|
249
|
+
}, [camera]);
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<canvas
|
|
253
|
+
ref={canvasRef}
|
|
254
|
+
className={className}
|
|
255
|
+
style={{
|
|
256
|
+
display: "block",
|
|
257
|
+
width: "100%",
|
|
258
|
+
height: typeof height === "number" ? `${height}px` : height,
|
|
259
|
+
touchAction: "none",
|
|
260
|
+
background: resolved.paper,
|
|
261
|
+
cursor: interactive ? "grab" : "default",
|
|
262
|
+
...style,
|
|
263
|
+
}}
|
|
264
|
+
onPointerDown={(e) => {
|
|
265
|
+
if (!interactive) return;
|
|
266
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
267
|
+
drag.current = { x: e.clientX, y: e.clientY };
|
|
268
|
+
}}
|
|
269
|
+
onPointerMove={(e) => {
|
|
270
|
+
if (!drag.current) return;
|
|
271
|
+
camera.panBy(e.clientX - drag.current.x, e.clientY - drag.current.y);
|
|
272
|
+
drag.current = { x: e.clientX, y: e.clientY };
|
|
273
|
+
renderRef.current();
|
|
274
|
+
}}
|
|
275
|
+
onPointerUp={(e) => {
|
|
276
|
+
drag.current = null;
|
|
277
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The same wrapper shape works in Vue, Svelte, or vanilla DOM — only the lifecycle
|
|
285
|
+
hook around `buildGraph` + `paint` changes.
|
|
286
|
+
|
|
287
|
+
## 2. Build and render it yourself
|
|
288
|
+
|
|
289
|
+
Not on React, or want full control? The wrapper above is just two steps: build
|
|
290
|
+
a graph with the engine, then paint it. You need an `ES2020` ESM runtime and a
|
|
291
|
+
`CanvasRenderingContext2D`.
|
|
292
|
+
|
|
293
|
+
**Step 1 — build a model and load its graph:**
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
import { parse } from "yaml";
|
|
297
|
+
import { MemoryStorage, NativeEngine } from "@neoloopy/cld-canvas";
|
|
298
|
+
|
|
299
|
+
const engine = new NativeEngine(new MemoryStorage(), parse, {
|
|
44
300
|
modelsRoot: "Models",
|
|
45
301
|
});
|
|
46
302
|
|
|
@@ -59,24 +315,13 @@ await engine.buildModel(model.folder, {
|
|
|
59
315
|
],
|
|
60
316
|
});
|
|
61
317
|
|
|
62
|
-
const graph = await engine.loadGraph(model.folder);
|
|
63
|
-
|
|
64
|
-
console.log(graph.nodes.map((node) => node.label));
|
|
65
|
-
console.log(graph.loops.length);
|
|
318
|
+
const graph = await engine.loadGraph(model.folder); // detects R/B loops
|
|
66
319
|
```
|
|
67
320
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
The renderer is intentionally UI-framework neutral. Build a renderable scene
|
|
71
|
-
from a `GraphView`, configure a `Camera`, then call `paint`.
|
|
321
|
+
**Step 2 — paint the graph to a canvas:**
|
|
72
322
|
|
|
73
323
|
```ts
|
|
74
|
-
import {
|
|
75
|
-
Camera,
|
|
76
|
-
LIGHT,
|
|
77
|
-
SceneCache,
|
|
78
|
-
paint,
|
|
79
|
-
} from "@neoloopy/cld-canvas";
|
|
324
|
+
import { Camera, LIGHT, SceneCache, paint } from "@neoloopy/cld-canvas";
|
|
80
325
|
|
|
81
326
|
const canvas = document.querySelector("canvas")!;
|
|
82
327
|
const ctx = canvas.getContext("2d")!;
|
|
@@ -111,124 +356,17 @@ if (scene) {
|
|
|
111
356
|
}
|
|
112
357
|
```
|
|
113
358
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
359
|
+
Wire `Camera.panBy`, `Camera.zoomAt`, `Camera.setScaleAt`, and `Camera.centerOn`
|
|
360
|
+
to your own pointer, wheel, trackpad, minimap, or toolbar controls. Canvas
|
|
361
|
+
rendering is stateless: keep selection, hover, and animation clocks in your app
|
|
362
|
+
state and pass them into `SceneCache` / `paint`.
|
|
117
363
|
|
|
118
|
-
##
|
|
364
|
+
## 3. The engine
|
|
119
365
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
a spec into a rendered diagram:
|
|
366
|
+
Past rendering, `NativeEngine` is a full create/edit/load API for neoloopy
|
|
367
|
+
models.
|
|
123
368
|
|
|
124
|
-
|
|
125
|
-
import { useEffect, useRef } from "react";
|
|
126
|
-
import { parse } from "yaml";
|
|
127
|
-
import {
|
|
128
|
-
MemoryStorage,
|
|
129
|
-
NativeEngine,
|
|
130
|
-
Camera,
|
|
131
|
-
SceneCache,
|
|
132
|
-
paint,
|
|
133
|
-
LIGHT,
|
|
134
|
-
} from "@neoloopy/cld-canvas";
|
|
135
|
-
import type { BuildSpec } from "@neoloopy/cld-canvas";
|
|
136
|
-
|
|
137
|
-
export function CldCanvas({ spec }: { spec: BuildSpec }) {
|
|
138
|
-
const ref = useRef<HTMLCanvasElement>(null);
|
|
139
|
-
|
|
140
|
-
useEffect(() => {
|
|
141
|
-
let alive = true;
|
|
142
|
-
(async () => {
|
|
143
|
-
const engine = new NativeEngine(new MemoryStorage(), parse, {
|
|
144
|
-
modelsRoot: "Models",
|
|
145
|
-
});
|
|
146
|
-
const model = await engine.createModel("model");
|
|
147
|
-
await engine.buildModel(model.folder, spec);
|
|
148
|
-
const graph = await engine.loadGraph(model.folder);
|
|
149
|
-
|
|
150
|
-
const canvas = ref.current;
|
|
151
|
-
const ctx = canvas?.getContext("2d");
|
|
152
|
-
if (!alive || !canvas || !ctx) return;
|
|
153
|
-
|
|
154
|
-
const dpr = window.devicePixelRatio || 1;
|
|
155
|
-
const w = canvas.clientWidth;
|
|
156
|
-
const h = canvas.clientHeight;
|
|
157
|
-
canvas.width = Math.floor(w * dpr);
|
|
158
|
-
canvas.height = Math.floor(h * dpr);
|
|
159
|
-
|
|
160
|
-
const camera = new Camera();
|
|
161
|
-
const sceneCache = new SceneCache();
|
|
162
|
-
const scene = sceneCache.build(graph, new Map(), new Map());
|
|
163
|
-
if (!scene) return;
|
|
164
|
-
sceneCache.fit(camera, w, h);
|
|
165
|
-
|
|
166
|
-
paint(ctx, scene, camera, LIGHT, {
|
|
167
|
-
cssWidth: w,
|
|
168
|
-
cssHeight: h,
|
|
169
|
-
dpr,
|
|
170
|
-
selectedNodeId: null,
|
|
171
|
-
selectedEdgeId: null,
|
|
172
|
-
selectedLoopKey: null,
|
|
173
|
-
liveNodeIds: new Set(),
|
|
174
|
-
linkPreview: null,
|
|
175
|
-
connectNodeId: null,
|
|
176
|
-
loopHighlight: null,
|
|
177
|
-
pulsePhase: 0,
|
|
178
|
-
flowPhase: 0,
|
|
179
|
-
});
|
|
180
|
-
})();
|
|
181
|
-
return () => {
|
|
182
|
-
alive = false;
|
|
183
|
-
};
|
|
184
|
-
}, [spec]);
|
|
185
|
-
|
|
186
|
-
return <canvas ref={ref} style={{ width: "100%", height: 400 }} />;
|
|
187
|
-
}
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
To make it interactive, keep one `Camera` and one `SceneCache` across renders,
|
|
191
|
-
wire `Camera.panBy` / `Camera.zoomAt` to pointer and wheel events, and repaint.
|
|
192
|
-
The same wrapper shape works in Vue, Svelte, or plain DOM — only the lifecycle
|
|
193
|
-
hook changes.
|
|
194
|
-
|
|
195
|
-
## Engine Concepts
|
|
196
|
-
|
|
197
|
-
### Storage
|
|
198
|
-
|
|
199
|
-
`NativeEngine` works against a small filesystem-like `VaultStorage` interface:
|
|
200
|
-
|
|
201
|
-
```ts
|
|
202
|
-
interface VaultStorage {
|
|
203
|
-
exists(path: string): Promise<boolean>;
|
|
204
|
-
read(path: string): Promise<string>;
|
|
205
|
-
write(path: string, data: string): Promise<void>;
|
|
206
|
-
remove(path: string): Promise<void>;
|
|
207
|
-
mkdirs(path: string): Promise<void>;
|
|
208
|
-
rmdir(path: string): Promise<void>;
|
|
209
|
-
move(from: string, to: string): Promise<void>;
|
|
210
|
-
list(path: string): Promise<{ files: string[]; folders: string[] }>;
|
|
211
|
-
}
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
Use `MemoryStorage` for tests, demos, and server-side transforms. In production,
|
|
215
|
-
adapt this interface to your host filesystem, browser persistence layer.
|
|
216
|
-
|
|
217
|
-
All paths are vault-relative and use `/` separators.
|
|
218
|
-
|
|
219
|
-
### Model Format
|
|
220
|
-
|
|
221
|
-
A model is stored as:
|
|
222
|
-
|
|
223
|
-
- `model.json` for model metadata and viewport
|
|
224
|
-
- `Nodes/*.md` for variable notes with YAML frontmatter and Markdown body
|
|
225
|
-
- `Loops/*.md` for feedback loop notes
|
|
226
|
-
- optional system notes such as `System.md`
|
|
227
|
-
|
|
228
|
-
The engine preserves unknown frontmatter keys so other neoloopy tools can add
|
|
229
|
-
metadata without being clobbered.
|
|
230
|
-
|
|
231
|
-
### Main Engine Methods
|
|
369
|
+
### Engine methods
|
|
232
370
|
|
|
233
371
|
```ts
|
|
234
372
|
const model = await engine.createModel("Model name");
|
|
@@ -256,7 +394,8 @@ await engine.relayout(model.folder);
|
|
|
256
394
|
await engine.setViewport(model.folder, { x: 0, y: 0, zoom: 1 });
|
|
257
395
|
```
|
|
258
396
|
|
|
259
|
-
Bulk build is
|
|
397
|
+
Bulk build (used above) is the fastest path for importing generated or external
|
|
398
|
+
CLD specs:
|
|
260
399
|
|
|
261
400
|
```ts
|
|
262
401
|
await engine.buildModel(model.folder, {
|
|
@@ -272,7 +411,43 @@ await engine.buildModel(model.folder, {
|
|
|
272
411
|
});
|
|
273
412
|
```
|
|
274
413
|
|
|
275
|
-
|
|
414
|
+
### Storage
|
|
415
|
+
|
|
416
|
+
`NativeEngine` works against a small filesystem-like `VaultStorage` interface:
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
interface VaultStorage {
|
|
420
|
+
exists(path: string): Promise<boolean>;
|
|
421
|
+
read(path: string): Promise<string>;
|
|
422
|
+
write(path: string, data: string): Promise<void>;
|
|
423
|
+
remove(path: string): Promise<void>;
|
|
424
|
+
mkdirs(path: string): Promise<void>;
|
|
425
|
+
rmdir(path: string): Promise<void>;
|
|
426
|
+
move(from: string, to: string): Promise<void>;
|
|
427
|
+
list(path: string): Promise<{ files: string[]; folders: string[] }>;
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Use `MemoryStorage` for tests, demos, and server-side transforms (it is not
|
|
432
|
+
persistent). In production, adapt this interface to your host filesystem or
|
|
433
|
+
browser persistence layer. All paths are vault-relative and use `/` separators.
|
|
434
|
+
`move` should preserve or update links when your host supports it — the Obsidian
|
|
435
|
+
adapter routes folder moves through Obsidian's file manager so wikilinks remain
|
|
436
|
+
valid.
|
|
437
|
+
|
|
438
|
+
### Model format
|
|
439
|
+
|
|
440
|
+
A model is stored as:
|
|
441
|
+
|
|
442
|
+
- `model.json` for model metadata and viewport
|
|
443
|
+
- `Nodes/*.md` for variable notes with YAML frontmatter and Markdown body
|
|
444
|
+
- `Loops/*.md` for feedback loop notes
|
|
445
|
+
- optional system notes such as `System.md`
|
|
446
|
+
|
|
447
|
+
The engine preserves unknown frontmatter keys so other neoloopy tools can add
|
|
448
|
+
metadata without being clobbered.
|
|
449
|
+
|
|
450
|
+
## 4. Exporting
|
|
276
451
|
|
|
277
452
|
`NativeEngine.export` supports `json`, `mermaid`, and `markdown`.
|
|
278
453
|
|
|
@@ -290,7 +465,11 @@ You can also use the lower-level renderer functions directly:
|
|
|
290
465
|
import { buildMermaid, render } from "@neoloopy/cld-canvas";
|
|
291
466
|
```
|
|
292
467
|
|
|
293
|
-
## Analysis
|
|
468
|
+
## 5. Analysis helpers
|
|
469
|
+
|
|
470
|
+

|
|
471
|
+
|
|
472
|
+
<sub>The same loop detection these helpers expose, surfaced in a neoloopy surface — a project-dynamics model with its reinforcing and balancing loops and the insight panel.</sub>
|
|
294
473
|
|
|
295
474
|
```ts
|
|
296
475
|
import { LoopGraph, endogeneity } from "@neoloopy/cld-canvas";
|
|
@@ -301,7 +480,7 @@ const metrics = loopGraph.metrics();
|
|
|
301
480
|
const summary = endogeneity(graph.nodes, loops);
|
|
302
481
|
```
|
|
303
482
|
|
|
304
|
-
## What
|
|
483
|
+
## What is exported
|
|
305
484
|
|
|
306
485
|
The package exports the public modules from `src/index.ts`, including:
|
|
307
486
|
|
|
@@ -313,13 +492,10 @@ The package exports the public modules from `src/index.ts`, including:
|
|
|
313
492
|
- Exporters: `render`, `buildMermaid`, `loopNoteKey`
|
|
314
493
|
- Analysis: `endogeneity`
|
|
315
494
|
|
|
316
|
-
## Notes for
|
|
495
|
+
## Notes for integrators
|
|
317
496
|
|
|
318
497
|
- The package is ESM-only. Use `import`, not `require`.
|
|
319
498
|
- The engine is asynchronous because real storage adapters usually perform I/O.
|
|
320
|
-
- `move` should preserve or update links when your host platform supports it.
|
|
321
|
-
The Obsidian adapter, for example, should route folder moves through Obsidian's
|
|
322
|
-
file manager so wikilinks remain valid.
|
|
323
499
|
- Canvas rendering is stateless. Keep selection, hover, animation clocks, bow
|
|
324
500
|
caches, and badge overrides in your application state and pass them to
|
|
325
501
|
`SceneCache` or `paint`.
|
package/dist/engine/engine.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { DetectedLoop, ModelManifest, VarType, VariableFile, Viewport } from "./types";
|
|
8
8
|
import { Rendered } from "./exporters";
|
|
9
9
|
import { ParentAnchor } from "./subsystemLinks";
|
|
10
|
+
import { ChildInterface } from "./publicInterface";
|
|
10
11
|
/** Lightweight model descriptor for pickers/lists (no notes loaded). */
|
|
11
12
|
export interface ModelRef {
|
|
12
13
|
id: string;
|
|
@@ -200,4 +201,13 @@ export interface NeoloopyEngine {
|
|
|
200
201
|
* Each anchor names the parent model + the linking variable. Empty when none.
|
|
201
202
|
*/
|
|
202
203
|
deriveParents(folder: string): Promise<ParentAnchor[]>;
|
|
204
|
+
/**
|
|
205
|
+
* The public interface of the child model linked from `varId`'s subsystem
|
|
206
|
+
* anchor: a display qualifier (the link alias, else the child model name) plus
|
|
207
|
+
* the labels of the child's public OUTPUTS and INPUTS. Null when the node has
|
|
208
|
+
* no subsystem link, or the child can't be resolved/loaded (fail closed —
|
|
209
|
+
* callers render a muted fallback). Read-only: the qualitative engine never
|
|
210
|
+
* edits the quant interface (publish/bind live in the app/CLI/MCP).
|
|
211
|
+
*/
|
|
212
|
+
childInterface(folder: string, varId: string): Promise<ChildInterface | null>;
|
|
203
213
|
}
|
|
@@ -26,6 +26,7 @@ import { contentSignature } from "./specHash";
|
|
|
26
26
|
import { Rendered } from "./exporters";
|
|
27
27
|
import { VaultStorage } from "./storage";
|
|
28
28
|
import { ParentAnchor } from "./subsystemLinks";
|
|
29
|
+
import { ChildInterface } from "./publicInterface";
|
|
29
30
|
export interface NativeEngineOptions {
|
|
30
31
|
/** Vault-relative base folder for newly created models (may be ""). */
|
|
31
32
|
modelsRoot: string;
|
|
@@ -102,6 +103,7 @@ export declare class NativeEngine implements NeoloopyEngine {
|
|
|
102
103
|
loopNotePath(folder: string, key: string): Promise<string | null>;
|
|
103
104
|
ensureSystemNote(folder: string): Promise<string>;
|
|
104
105
|
deriveParents(folder: string): Promise<ParentAnchor[]>;
|
|
106
|
+
childInterface(folder: string, varId: string): Promise<ChildInterface | null>;
|
|
105
107
|
export(folder: string, format: ExportFormat): Promise<Rendered>;
|
|
106
108
|
/** The model's `Nodes/` subfolder, where variable notes now live. */
|
|
107
109
|
private nodesDir;
|
|
@@ -30,7 +30,8 @@ import { autoLayout } from "./layout";
|
|
|
30
30
|
import { render, } from "./exporters";
|
|
31
31
|
import { LoopType } from "./types";
|
|
32
32
|
import { baseName, joinPath, parentPath, } from "./storage";
|
|
33
|
-
import { deriveParentAnchors } from "./subsystemLinks";
|
|
33
|
+
import { deriveParentAnchors, linkPointsToModel, parseSubsystemLink, } from "./subsystemLinks";
|
|
34
|
+
import { isPublicInput, isPublicOutput, } from "./publicInterface";
|
|
34
35
|
/** Non-variable notes that share a model folder. */
|
|
35
36
|
const SPECIAL_NOTES = new Set(["System.md", "Futures.md", "CLA.md"]);
|
|
36
37
|
const MAX_SCAN_DEPTH = 12;
|
|
@@ -532,6 +533,33 @@ export class NativeEngine {
|
|
|
532
533
|
return [];
|
|
533
534
|
return deriveParentAnchors({ folder: current.folder, name: current.name }, models.filter((m) => m.folder !== folder).map((m) => ({ folder: m.folder, name: m.name })), (f) => this.loadNotes(f));
|
|
534
535
|
}
|
|
536
|
+
async childInterface(folder, varId) {
|
|
537
|
+
const nodes = await this.loadNotes(folder);
|
|
538
|
+
const anchor = nodes.find((n) => n.id === varId);
|
|
539
|
+
const raw = (anchor?.subsystem ?? "").trim();
|
|
540
|
+
if (!raw)
|
|
541
|
+
return null;
|
|
542
|
+
const { alias } = parseSubsystemLink(raw);
|
|
543
|
+
const models = await this.listModels();
|
|
544
|
+
const match = models.find((m) => linkPointsToModel(raw, { folder: m.folder, name: m.name }));
|
|
545
|
+
if (!match)
|
|
546
|
+
return null;
|
|
547
|
+
try {
|
|
548
|
+
const childNodes = await this.loadNotes(match.folder);
|
|
549
|
+
const outputs = [];
|
|
550
|
+
const inputs = [];
|
|
551
|
+
for (const v of childNodes) {
|
|
552
|
+
if (isPublicOutput(v))
|
|
553
|
+
outputs.push(v.label || v.id);
|
|
554
|
+
if (isPublicInput(v))
|
|
555
|
+
inputs.push(v.label || v.id);
|
|
556
|
+
}
|
|
557
|
+
return { qualifier: alias ?? match.name, outputs, inputs };
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
535
563
|
// ---- export --------------------------------------------------------------
|
|
536
564
|
async export(folder, format) {
|
|
537
565
|
const view = await this.loadGraph(folder);
|
|
@@ -1091,18 +1119,11 @@ function slug(s) {
|
|
|
1091
1119
|
}
|
|
1092
1120
|
function randomHex(bytes) {
|
|
1093
1121
|
const arr = new Uint8Array(bytes);
|
|
1094
|
-
// Web Crypto is exposed as the global `crypto` in both Obsidian (Electron)
|
|
1095
|
-
// and Node (this pure engine's vitest tests). Referenced bare — not via
|
|
1096
|
-
// `window`/`globalThis` — so it resolves under Node too; `typeof`-guarded so
|
|
1097
|
-
// it falls back to Math.random anywhere it is unavailable.
|
|
1098
1122
|
const c = typeof crypto !== "undefined" ? crypto : undefined;
|
|
1099
|
-
if (c
|
|
1100
|
-
|
|
1101
|
-
}
|
|
1102
|
-
else {
|
|
1103
|
-
for (let i = 0; i < bytes; i++)
|
|
1104
|
-
arr[i] = Math.floor(Math.random() * 256);
|
|
1123
|
+
if (!c || typeof c.getRandomValues !== "function") {
|
|
1124
|
+
throw new Error("Web Crypto is required to generate neoloopy ids.");
|
|
1105
1125
|
}
|
|
1126
|
+
c.getRandomValues(arr);
|
|
1106
1127
|
let s = "";
|
|
1107
1128
|
for (const b of arr)
|
|
1108
1129
|
s += b.toString(16).padStart(2, "0");
|
|
@@ -1112,9 +1133,7 @@ function genVarId() {
|
|
|
1112
1133
|
return `var_${randomHex(4)}`;
|
|
1113
1134
|
}
|
|
1114
1135
|
function genModelId() {
|
|
1115
|
-
|
|
1116
|
-
const hex = n.toString(16);
|
|
1117
|
-
return `mdl_${hex.slice(-6)}`;
|
|
1136
|
+
return `mdl_${randomHex(4)}`;
|
|
1118
1137
|
}
|
|
1119
1138
|
/** Re-export so callers don't need a second import for the signature helper. */
|
|
1120
1139
|
export { contentSignature };
|
package/dist/engine/noteCodec.js
CHANGED
|
@@ -79,10 +79,18 @@ function tagsFrom(v) {
|
|
|
79
79
|
function rawScalar(frontmatter, key) {
|
|
80
80
|
if (frontmatter === null)
|
|
81
81
|
return undefined;
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
let s;
|
|
83
|
+
for (const line of frontmatter.split("\n")) {
|
|
84
|
+
const colon = line.indexOf(":");
|
|
85
|
+
if (colon < 0)
|
|
86
|
+
continue;
|
|
87
|
+
if (line.slice(0, colon).trim() !== key)
|
|
88
|
+
continue;
|
|
89
|
+
s = line.slice(colon + 1).trim();
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (s === undefined)
|
|
84
93
|
return undefined;
|
|
85
|
-
let s = m[1];
|
|
86
94
|
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
87
95
|
s = s.slice(1, -1);
|
|
88
96
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-interface readers — the TypeScript mirror of the READ side of
|
|
3
|
+
* `core/lib/quant/public_interface.dart`. In a subsystem-composed model a
|
|
4
|
+
* variable is exposed to its parent by tagging it under `extra.quant.visibility`
|
|
5
|
+
* (`'input'` | `'output'`; absent ⇒ private); a parent drives a child's public
|
|
6
|
+
* input via `inputBindings` declared on the anchor node (the node carrying the
|
|
7
|
+
* `subsystem` link). The qualitative plugin only READS these — publishing and
|
|
8
|
+
* binding are quant authoring, which lives in the app/CLI/MCP. Both fields are
|
|
9
|
+
* preserved verbatim on write (unknown-key carry, format rule §3), so reading
|
|
10
|
+
* them never risks the data.
|
|
11
|
+
*/
|
|
12
|
+
import { VariableFile } from "./types";
|
|
13
|
+
export type Visibility = "input" | "output";
|
|
14
|
+
/** `'input'` | `'output'` | `null` (private — the default). */
|
|
15
|
+
export declare function quantVisibility(v: VariableFile): Visibility | null;
|
|
16
|
+
export declare function isPublicInput(v: VariableFile): boolean;
|
|
17
|
+
export declare function isPublicOutput(v: VariableFile): boolean;
|
|
18
|
+
/** A parent-side wiring that drives a linked child's public input. */
|
|
19
|
+
export interface InputBinding {
|
|
20
|
+
/** Qualifier of the linked child (link alias or model name). */
|
|
21
|
+
child: string;
|
|
22
|
+
/** Child public-input label or id. */
|
|
23
|
+
target: string;
|
|
24
|
+
/** Parent-scope expression that drives the input. */
|
|
25
|
+
expr: string;
|
|
26
|
+
}
|
|
27
|
+
/** Input bindings declared on this anchor node (empty when none). */
|
|
28
|
+
export declare function quantInputBindings(v: VariableFile): InputBinding[];
|
|
29
|
+
/** A child model's public interface as resolved through a parent's subsystem link. */
|
|
30
|
+
export interface ChildInterface {
|
|
31
|
+
/** Display qualifier — the link alias, else the child model name. */
|
|
32
|
+
qualifier: string;
|
|
33
|
+
/** Labels of the child's public outputs (offered to the parent). */
|
|
34
|
+
outputs: string[];
|
|
35
|
+
/** Labels of the child's public inputs (the parent may drive). */
|
|
36
|
+
inputs: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A child output as a qualified reference: bracket form when the label has a
|
|
40
|
+
* space (`ReworkCycle.[Defect Rate]`), bare otherwise. Mirrors the app's `_ref`
|
|
41
|
+
* in `subsystem_interface_section.dart`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function qualifiedRef(qualifier: string, label: string): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-interface readers — the TypeScript mirror of the READ side of
|
|
3
|
+
* `core/lib/quant/public_interface.dart`. In a subsystem-composed model a
|
|
4
|
+
* variable is exposed to its parent by tagging it under `extra.quant.visibility`
|
|
5
|
+
* (`'input'` | `'output'`; absent ⇒ private); a parent drives a child's public
|
|
6
|
+
* input via `inputBindings` declared on the anchor node (the node carrying the
|
|
7
|
+
* `subsystem` link). The qualitative plugin only READS these — publishing and
|
|
8
|
+
* binding are quant authoring, which lives in the app/CLI/MCP. Both fields are
|
|
9
|
+
* preserved verbatim on write (unknown-key carry, format rule §3), so reading
|
|
10
|
+
* them never risks the data.
|
|
11
|
+
*/
|
|
12
|
+
function quantBlock(v) {
|
|
13
|
+
const q = v.extra["quant"];
|
|
14
|
+
return q && typeof q === "object" ? q : {};
|
|
15
|
+
}
|
|
16
|
+
/** `'input'` | `'output'` | `null` (private — the default). */
|
|
17
|
+
export function quantVisibility(v) {
|
|
18
|
+
const s = String(quantBlock(v)["visibility"] ?? "").trim();
|
|
19
|
+
return s === "input" || s === "output" ? s : null;
|
|
20
|
+
}
|
|
21
|
+
export function isPublicInput(v) {
|
|
22
|
+
return quantVisibility(v) === "input";
|
|
23
|
+
}
|
|
24
|
+
export function isPublicOutput(v) {
|
|
25
|
+
return quantVisibility(v) === "output";
|
|
26
|
+
}
|
|
27
|
+
/** Input bindings declared on this anchor node (empty when none). */
|
|
28
|
+
export function quantInputBindings(v) {
|
|
29
|
+
const raw = quantBlock(v)["inputBindings"];
|
|
30
|
+
if (!Array.isArray(raw))
|
|
31
|
+
return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const e of raw) {
|
|
34
|
+
if (e && typeof e === "object") {
|
|
35
|
+
const m = e;
|
|
36
|
+
out.push({
|
|
37
|
+
child: String(m["child"] ?? ""),
|
|
38
|
+
target: String(m["target"] ?? ""),
|
|
39
|
+
expr: String(m["expr"] ?? ""),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A child output as a qualified reference: bracket form when the label has a
|
|
47
|
+
* space (`ReworkCycle.[Defect Rate]`), bare otherwise. Mirrors the app's `_ref`
|
|
48
|
+
* in `subsystem_interface_section.dart`.
|
|
49
|
+
*/
|
|
50
|
+
export function qualifiedRef(qualifier, label) {
|
|
51
|
+
return `${qualifier}.${label.includes(" ") ? `[${label}]` : label}`;
|
|
52
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./engine/engine";
|
|
|
13
13
|
export * from "./engine/exporters";
|
|
14
14
|
export * from "./engine/analysis";
|
|
15
15
|
export * from "./engine/subsystemLinks";
|
|
16
|
+
export * from "./engine/publicInterface";
|
|
16
17
|
export * from "./engine/loopKey";
|
|
17
18
|
export * from "./engine/loopNote";
|
|
18
19
|
export * from "./engine/noteNaming";
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./engine/engine";
|
|
|
13
13
|
export * from "./engine/exporters";
|
|
14
14
|
export * from "./engine/analysis";
|
|
15
15
|
export * from "./engine/subsystemLinks";
|
|
16
|
+
export * from "./engine/publicInterface";
|
|
16
17
|
export * from "./engine/loopKey";
|
|
17
18
|
export * from "./engine/loopNote";
|
|
18
19
|
export * from "./engine/noteNaming";
|
package/package.json
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neoloopy/cld-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"author": "neoloopy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Framework-agnostic CLD canvas renderer + in-memory edit engine (shared by the neoloopy Obsidian plugin and neoloopy.com).",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"cld",
|
|
9
9
|
"causal-loop-diagram",
|
|
10
|
+
"causal loop diagram",
|
|
10
11
|
"systems-thinking",
|
|
12
|
+
"systems thinking",
|
|
11
13
|
"system-dynamics",
|
|
14
|
+
"system dynamics",
|
|
12
15
|
"feedback-loops",
|
|
16
|
+
"feedback loops",
|
|
17
|
+
"systems diagram",
|
|
18
|
+
"systems mapping",
|
|
19
|
+
"causal map",
|
|
20
|
+
"loopy",
|
|
13
21
|
"canvas",
|
|
14
22
|
"diagram",
|
|
23
|
+
"diagram renderer",
|
|
24
|
+
"cld renderer",
|
|
15
25
|
"visualization",
|
|
16
26
|
"graph",
|
|
17
27
|
"neoloopy"
|