@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 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
+ ![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)
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
+ ![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>
473
+
466
474
  ```ts
467
475
  import { LoopGraph, endogeneity } from "@neoloopy/cld-canvas";
468
476
 
@@ -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.3",
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"