@neoloopy/cld-canvas 0.1.3 → 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 +8 -0
- 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
|
@@ -5,6 +5,10 @@ engine used by neoloopy surfaces, including the Obsidian plugin and web app.
|
|
|
5
5
|
Pure TypeScript, ESM-only, with no runtime dependency on Obsidian, React, Vue,
|
|
6
6
|
or a server.
|
|
7
7
|
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
<sub>The canvas this package renders — an SIR epidemic model open in Obsidian with the link-editing toolbar.</sub>
|
|
11
|
+
|
|
8
12
|
**This README is ordered easy → hard** — start at the top with the one-tag React
|
|
9
13
|
drop-in and go only as deep as you need:
|
|
10
14
|
|
|
@@ -463,6 +467,10 @@ import { buildMermaid, render } from "@neoloopy/cld-canvas";
|
|
|
463
467
|
|
|
464
468
|
## 5. Analysis helpers
|
|
465
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>
|
|
473
|
+
|
|
466
474
|
```ts
|
|
467
475
|
import { LoopGraph, endogeneity } from "@neoloopy/cld-canvas";
|
|
468
476
|
|
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"
|