@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 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
- The package is pure TypeScript, ESM-only, and has no runtime dependency on
7
- Obsidian, React, Vue, or a server. It gives you two pieces:
8
+ ![An SIR epidemic causal-loop diagram open on the neoloopy canvas in Obsidian, with the link-editing toolbar visible](https://raw.githubusercontent.com/frozenabe/neoloopy-obsidian/main/screenshots/example-model.png)
8
9
 
9
- - A vault-compatible CLD engine for creating, editing, loading, exporting, and
10
- analyzing neoloopy models.
11
- - A canvas rendering layer for drawing nodes, causal links, polarity chips,
12
- loop badges, selection state, and pan/zoom views.
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`, provide a YAML parser for note frontmatter. The
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
- ## Requirements
33
+ ## 1. React, in one tag (easiest)
28
34
 
29
- - TypeScript or JavaScript with ESM imports
30
- - `ES2020` runtime support
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
- ## Quick Start
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
- ```ts
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
- const storage = new MemoryStorage();
43
- const engine = new NativeEngine(storage, parse, {
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
- ## Rendering to Canvas
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
- Use `Camera.panBy`, `Camera.zoomAt`, `Camera.setScaleAt`, and
115
- `Camera.centerOn` to wire your own pointer, wheel, trackpad, minimap, or toolbar
116
- controls.
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
- ## Using It in React (or any framework)
364
+ ## 3. The engine
119
365
 
120
- There is no framework-specific component `paint` only needs a `<canvas>` 2D
121
- context, so a thin wrapper is all it takes. A minimal React component that turns
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
- ```tsx
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 useful when importing generated or external CLD specs:
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
- ## Exporting
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 Helpers
468
+ ## 5. Analysis helpers
469
+
470
+ ![A project-dynamics model — effort, schedule pressure, rework, burnout — showing detected reinforcing and balancing loops alongside the insight panel](https://raw.githubusercontent.com/frozenabe/neoloopy-obsidian/main/screenshots/sample-model.png)
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 Is Exported
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 Integrators
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`.
@@ -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 && typeof c.getRandomValues === "function") {
1100
- c.getRandomValues(arr);
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
- const n = Date.now() * 1000 + Math.floor(Math.random() * 1000);
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 };
@@ -79,10 +79,18 @@ function tagsFrom(v) {
79
79
  function rawScalar(frontmatter, key) {
80
80
  if (frontmatter === null)
81
81
  return undefined;
82
- const m = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, "m").exec(frontmatter);
83
- if (!m)
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.2",
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"