@neoloopy/cld-canvas 0.1.0

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.
Files changed (41) hide show
  1. package/dist/engine/analysis.d.ts +15 -0
  2. package/dist/engine/analysis.js +30 -0
  3. package/dist/engine/engine.d.ts +203 -0
  4. package/dist/engine/engine.js +7 -0
  5. package/dist/engine/exporters.d.ts +45 -0
  6. package/dist/engine/exporters.js +110 -0
  7. package/dist/engine/layout.d.ts +19 -0
  8. package/dist/engine/layout.js +142 -0
  9. package/dist/engine/loopGraph.d.ts +51 -0
  10. package/dist/engine/loopGraph.js +0 -0
  11. package/dist/engine/loopKey.d.ts +24 -0
  12. package/dist/engine/loopKey.js +32 -0
  13. package/dist/engine/loopNote.d.ts +40 -0
  14. package/dist/engine/loopNote.js +130 -0
  15. package/dist/engine/nativeEngine.d.ts +175 -0
  16. package/dist/engine/nativeEngine.js +1120 -0
  17. package/dist/engine/noteCodec.d.ts +29 -0
  18. package/dist/engine/noteCodec.js +254 -0
  19. package/dist/engine/noteNaming.d.ts +18 -0
  20. package/dist/engine/noteNaming.js +28 -0
  21. package/dist/engine/specHash.d.ts +37 -0
  22. package/dist/engine/specHash.js +86 -0
  23. package/dist/engine/storage.d.ts +49 -0
  24. package/dist/engine/storage.js +116 -0
  25. package/dist/engine/subsystemLinks.d.ts +29 -0
  26. package/dist/engine/subsystemLinks.js +55 -0
  27. package/dist/engine/types.d.ts +103 -0
  28. package/dist/engine/types.js +155 -0
  29. package/dist/index.d.ts +19 -0
  30. package/dist/index.js +19 -0
  31. package/dist/view/camera.d.ts +42 -0
  32. package/dist/view/camera.js +75 -0
  33. package/dist/view/geometry.d.ts +83 -0
  34. package/dist/view/geometry.js +408 -0
  35. package/dist/view/painter.d.ts +63 -0
  36. package/dist/view/painter.js +471 -0
  37. package/dist/view/sceneCache.d.ts +61 -0
  38. package/dist/view/sceneCache.js +134 -0
  39. package/dist/view/theme.d.ts +57 -0
  40. package/dist/view/theme.js +90 -0
  41. package/package.json +17 -0
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Pure graph-analysis helpers for the insight panel — no DOM, no I/O.
3
+ *
4
+ * - `endogeneity` mirrors the app's `CanvasController.endogeneity()` (keyed by
5
+ * variable id): how much of the system sits inside feedback vs. drives it from
6
+ * outside.
7
+ */
8
+ import { DetectedLoop, VariableFile } from "./types";
9
+ export interface EndogeneityResult {
10
+ total: number;
11
+ inLoop: number;
12
+ exogenous: string[];
13
+ openLoop: string[];
14
+ }
15
+ export declare function endogeneity(nodes: VariableFile[], loops: DetectedLoop[]): EndogeneityResult;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure graph-analysis helpers for the insight panel — no DOM, no I/O.
3
+ *
4
+ * - `endogeneity` mirrors the app's `CanvasController.endogeneity()` (keyed by
5
+ * variable id): how much of the system sits inside feedback vs. drives it from
6
+ * outside.
7
+ */
8
+ export function endogeneity(nodes, loops) {
9
+ const ids = new Set(nodes.map((n) => n.id));
10
+ const inLoop = new Set();
11
+ for (const l of loops)
12
+ for (const id of l.nodeIds)
13
+ inLoop.add(id);
14
+ const sources = new Set();
15
+ const targets = new Set();
16
+ for (const n of nodes) {
17
+ for (const lk of n.links) {
18
+ if (!ids.has(lk.to))
19
+ continue;
20
+ sources.add(n.id);
21
+ targets.add(lk.to);
22
+ }
23
+ }
24
+ return {
25
+ total: nodes.length,
26
+ inLoop: nodes.filter((n) => inLoop.has(n.id)).length,
27
+ exogenous: nodes.filter((n) => !targets.has(n.id) && sources.has(n.id)).map((n) => n.id),
28
+ openLoop: nodes.filter((n) => !inLoop.has(n.id)).map((n) => n.id),
29
+ };
30
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The engine seam. Everything UI-facing talks to a `NeoloopyEngine`; the sole
3
+ * implementation is `NativeEngine` (pure TypeScript, runs on every platform
4
+ * including mobile, no binary, no network). The interface is kept narrow so the
5
+ * view and tests can depend on it (and mock it) without touching internals.
6
+ */
7
+ import { DetectedLoop, ModelManifest, VarType, VariableFile, Viewport } from "./types";
8
+ import { Rendered } from "./exporters";
9
+ import { ParentAnchor } from "./subsystemLinks";
10
+ /** Lightweight model descriptor for pickers/lists (no notes loaded). */
11
+ export interface ModelRef {
12
+ id: string;
13
+ name: string;
14
+ /** Vault-relative folder path that holds `model.json` + the notes. */
15
+ folder: string;
16
+ /**
17
+ * Organizational grouping label (the `model.json` `folder` field, set via
18
+ * `set-folder`) — NOT the directory path above. Null/empty when ungrouped.
19
+ * The picker groups by this, mirroring the app's folder-grouped models screen.
20
+ */
21
+ group: string | null;
22
+ modified: string;
23
+ variableCount: number;
24
+ /** Carries quantitative data — read-only in this qualitative engine. */
25
+ quant: boolean;
26
+ }
27
+ /** A fully loaded model plus its detected loops and labels. */
28
+ export interface GraphView {
29
+ folder: string;
30
+ manifest: ModelManifest;
31
+ nodes: VariableFile[];
32
+ loops: DetectedLoop[];
33
+ /** loop.key -> "R1" / "B2" badge label. */
34
+ labels: Map<string, string>;
35
+ quant: boolean;
36
+ }
37
+ export interface NewVariable {
38
+ label: string;
39
+ type?: VarType;
40
+ x?: number;
41
+ y?: number;
42
+ group?: string;
43
+ shared?: string;
44
+ claLayer?: string;
45
+ }
46
+ /**
47
+ * Partial update for a variable. A field left `undefined` is untouched; an
48
+ * explicit `null` on a nullable field clears it.
49
+ */
50
+ export interface VariablePatch {
51
+ label?: string;
52
+ type?: VarType;
53
+ group?: string | null;
54
+ shared?: string | null;
55
+ claLayer?: string | null;
56
+ status?: string | null;
57
+ tags?: string[];
58
+ body?: string;
59
+ }
60
+ /**
61
+ * A partial edit to a variable's quantitative definition (`extra.quant`). A
62
+ * field left `undefined` is untouched; an empty string clears that field. When
63
+ * the block ends up empty it is dropped so plain notes stay clean.
64
+ */
65
+ export interface QuantPatch {
66
+ equation?: string;
67
+ initial?: string;
68
+ units?: string;
69
+ }
70
+ export interface LinkInit {
71
+ polarity?: "+" | "-";
72
+ delay?: boolean;
73
+ indirect?: boolean;
74
+ nonlinear?: boolean;
75
+ weight?: number;
76
+ curvature?: number;
77
+ confidence?: number;
78
+ basis?: string;
79
+ }
80
+ /** Like `LinkInit`, but `curvature: null` explicitly clears it (auto-bow). */
81
+ export interface LinkPatch {
82
+ polarity?: "+" | "-";
83
+ delay?: boolean;
84
+ indirect?: boolean;
85
+ nonlinear?: boolean;
86
+ weight?: number;
87
+ curvature?: number | null;
88
+ confidence?: number;
89
+ basis?: string;
90
+ }
91
+ export interface BuildSpec {
92
+ variables: Array<{
93
+ id?: string;
94
+ label: string;
95
+ type?: VarType;
96
+ group?: string;
97
+ shared?: string;
98
+ }>;
99
+ /** `from`/`to` may be a variable label or id (labels resolved first). */
100
+ links: Array<{
101
+ from: string;
102
+ to: string;
103
+ } & LinkInit>;
104
+ /** Auto-layout positions after building (default true). */
105
+ layout?: boolean;
106
+ }
107
+ export type ExportFormat = "json" | "mermaid" | "markdown";
108
+ export interface NeoloopyEngine {
109
+ listModels(): Promise<ModelRef[]>;
110
+ createModel(name: string): Promise<ModelRef>;
111
+ /**
112
+ * Rename a model from the canvas (title is canonical): set the `model.json`
113
+ * `name` AND re-slug + move the folder to match, suffixing `-2/-3…` on
114
+ * collision. The move is link-aware so wikilinks and subsystem anchors follow.
115
+ * Skips the move when the slug is unchanged. Rejects a blank title.
116
+ */
117
+ renameModel(folder: string, name: string): Promise<ModelRef>;
118
+ /**
119
+ * The inverse of renameModel for an *external* folder rename (folder is
120
+ * canonical): set the `model.json` `name` to the new folder name IN PLACE,
121
+ * without re-slugging or moving the directory. Rejects a blank title.
122
+ */
123
+ retitleModel(folder: string, name: string): Promise<ModelRef>;
124
+ /**
125
+ * The node-level inverse of retitleModel: after the user renames a variable
126
+ * note in the vault (`Nodes/<stem>.md`), set its label to the de-slugged
127
+ * stem. The stable `var_…` id and inbound links are untouched. No-op when the
128
+ * file is gone or the label already matches.
129
+ */
130
+ relabelNodeFromFilename(folder: string, fileStem: string): Promise<void>;
131
+ deleteModel(folder: string): Promise<void>;
132
+ /**
133
+ * Duplicate a model as a NEW model with fresh ids (model + every variable,
134
+ * plus re-pointed loop-note members and System anchor). `shared` keys are
135
+ * preserved so the copy stays in the cross-model graph. Lands as a sibling of
136
+ * the source, titled "<name> (copy)". Returns the copy's ref.
137
+ */
138
+ duplicateModel(srcFolder: string): Promise<ModelRef>;
139
+ /**
140
+ * Re-key models that share an id (a raw Obsidian copy clones ids verbatim):
141
+ * keep the oldest, re-key every newer sibling. Returns one row per re-keyed
142
+ * folder; empty when all ids are already unique.
143
+ */
144
+ healDuplicateIds(): Promise<Array<{
145
+ folder: string;
146
+ oldId: string;
147
+ newId: string;
148
+ }>>;
149
+ loadGraph(folder: string): Promise<GraphView>;
150
+ addVariable(folder: string, init: NewVariable): Promise<VariableFile>;
151
+ updateVariable(folder: string, id: string, patch: VariablePatch): Promise<VariableFile>;
152
+ /**
153
+ * Edit a variable's quantitative definition (`extra.quant`): equation,
154
+ * initial value, and/or units. Merges into any existing block, preserving
155
+ * keys it does not manage (e.g. a subscript dimension).
156
+ */
157
+ setEquation(folder: string, id: string, patch: QuantPatch): Promise<VariableFile>;
158
+ /** Cosmetic position change — never bumps rev/modified. */
159
+ moveVariable(folder: string, id: string, x: number, y: number): Promise<void>;
160
+ removeVariable(folder: string, id: string): Promise<void>;
161
+ /**
162
+ * Link (or clear, with `child=null`) a node's subsystem anchor — the same
163
+ * `[[../<childDir>/System|<Child Name>]]` wikilink the app/CLI write, so the
164
+ * drill-in mark round-trips across all surfaces.
165
+ */
166
+ setSubsystem(folder: string, varId: string, child: {
167
+ folder: string;
168
+ name: string;
169
+ } | null): Promise<void>;
170
+ addLink(folder: string, from: string, to: string, init?: LinkInit): Promise<void>;
171
+ updateLink(folder: string, from: string, to: string, patch: LinkPatch): Promise<void>;
172
+ removeLink(folder: string, from: string, to: string): Promise<void>;
173
+ buildModel(folder: string, spec: BuildSpec): Promise<void>;
174
+ relayout(folder: string): Promise<void>;
175
+ setViewport(folder: string, viewport: Viewport): Promise<void>;
176
+ export(folder: string, format: ExportFormat): Promise<Rendered>;
177
+ /**
178
+ * Loop notes — one `Loops/<slug>.md` file per feedback loop, identity-anchored
179
+ * in frontmatter (the same files the Dart app/CLI write). Resolved into a map
180
+ * keyed by `<R|B>:<sorted unique variable names>` so both surfaces agree.
181
+ * `getLoopNotes` returns the body text per live loop; `setLoopNote` writes it
182
+ * into the identity-matched file (preserving title/valence/archetype).
183
+ */
184
+ getLoopNotes(folder: string): Promise<Record<string, string>>;
185
+ setLoopNote(folder: string, key: string, text: string): Promise<void>;
186
+ /**
187
+ * Vault-relative path of the canonical `Loops/*.md` file for `key`, creating
188
+ * an empty anchored note if none exists. Null when no live loop carries that
189
+ * key. The canvas opens this file directly for editing.
190
+ */
191
+ loopNotePath(folder: string, key: string): Promise<string | null>;
192
+ /**
193
+ * Ensure the model has a `System.md`, creating a minimal valid one (frontmatter
194
+ * `model: "<id>"`) when absent. Returns its vault-relative path. The canvas
195
+ * opens this file directly.
196
+ */
197
+ ensureSystemNote(folder: string): Promise<string>;
198
+ /**
199
+ * Parent systems of `folder` — models whose nodes anchor it as a subsystem.
200
+ * Each anchor names the parent model + the linking variable. Empty when none.
201
+ */
202
+ deriveParents(folder: string): Promise<ParentAnchor[]>;
203
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The engine seam. Everything UI-facing talks to a `NeoloopyEngine`; the sole
3
+ * implementation is `NativeEngine` (pure TypeScript, runs on every platform
4
+ * including mobile, no binary, no network). The interface is kept narrow so the
5
+ * view and tests can depend on it (and mock it) without touching internals.
6
+ */
7
+ export {};
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Export renderers — TypeScript port of the qualitative subset of
3
+ * `core/lib/cli/formats.dart` (`buildMermaid`, `loopNoteKey`, and `render` for
4
+ * json | markdown | mermaid). SVG and Cypher are intentionally out of v1 scope.
5
+ * Operates on plain node/edge/loop records so it stays storage-agnostic.
6
+ */
7
+ export interface ExportNode {
8
+ id: string;
9
+ name: string;
10
+ kind?: string;
11
+ x?: number;
12
+ y?: number;
13
+ group?: string;
14
+ }
15
+ export interface ExportEdge {
16
+ id?: string;
17
+ source: string;
18
+ target: string;
19
+ polarity?: number;
20
+ delay?: boolean;
21
+ curvature?: number;
22
+ dashed?: boolean;
23
+ weight?: number;
24
+ }
25
+ export interface ExportLoop {
26
+ loop: string[];
27
+ type: string;
28
+ label?: string;
29
+ }
30
+ export interface Rendered {
31
+ content: string;
32
+ mime: string;
33
+ ext: string;
34
+ }
35
+ export interface RenderOpts {
36
+ notes?: Record<string, string>;
37
+ valence?: Record<string, string>;
38
+ titles?: Record<string, string>;
39
+ hypothesis?: Record<string, unknown>;
40
+ }
41
+ /** Stable identity for a loop's note: `<R|B>:<sorted unique variable names>`. */
42
+ export declare function loopNoteKey(loop: string[], typeStr: string): string;
43
+ export declare function buildMermaid(nodes: ExportNode[], edges: ExportEdge[]): string;
44
+ /** Render a model in json | mermaid | markdown. */
45
+ export declare function render(fmt: string, modelId: string, name: string, nodes: ExportNode[], edges: ExportEdge[], loops: ExportLoop[], opts?: RenderOpts): Rendered;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Export renderers — TypeScript port of the qualitative subset of
3
+ * `core/lib/cli/formats.dart` (`buildMermaid`, `loopNoteKey`, and `render` for
4
+ * json | markdown | mermaid). SVG and Cypher are intentionally out of v1 scope.
5
+ * Operates on plain node/edge/loop records so it stays storage-agnostic.
6
+ */
7
+ /** Stable identity for a loop's note: `<R|B>:<sorted unique variable names>`. */
8
+ export function loopNoteKey(loop, typeStr) {
9
+ const core = loop.length > 1 && loop.length > 0 && loop[0] === loop[loop.length - 1]
10
+ ? loop.slice(0, loop.length - 1)
11
+ : loop;
12
+ const letter = typeStr.toUpperCase().startsWith("R") ? "R" : "B";
13
+ const uniq = [...new Set(core.map(String))].sort();
14
+ return `${letter}:${uniq.join("|")}`;
15
+ }
16
+ export function buildMermaid(nodes, edges) {
17
+ const key = new Map();
18
+ nodes.forEach((n, i) => key.set(String(n.id), `v${i}`));
19
+ const lines = ["graph LR"];
20
+ for (const n of nodes) {
21
+ const label = n.name.replace(/"/g, "'");
22
+ lines.push(` ${key.get(String(n.id))}["${label}"]`);
23
+ }
24
+ for (const e of edges) {
25
+ const s = key.get(String(e.source));
26
+ const t = key.get(String(e.target));
27
+ if (!s || !t)
28
+ continue;
29
+ const sym = (e.polarity ?? 1) >= 0 ? "+" : "−";
30
+ const arrow = e.delay === true ? "-.->" : "-->";
31
+ lines.push(` ${s} ${arrow}|${sym}| ${t}`);
32
+ }
33
+ return lines.join("\n");
34
+ }
35
+ function loopJson(l, titles, valence, notes) {
36
+ const key = loopNoteKey(l.loop, l.type);
37
+ const out = { ...l };
38
+ if (titles[key] !== undefined)
39
+ out["title"] = titles[key];
40
+ if (valence[key] !== undefined)
41
+ out["valence"] = valence[key];
42
+ if (notes[key] !== undefined)
43
+ out["note"] = notes[key];
44
+ return out;
45
+ }
46
+ /** Render a model in json | mermaid | markdown. */
47
+ export function render(fmt, modelId, name, nodes, edges, loops, opts = {}) {
48
+ fmt = fmt.toLowerCase();
49
+ const notes = opts.notes ?? {};
50
+ const valence = opts.valence ?? {};
51
+ const titles = opts.titles ?? {};
52
+ if (fmt === "json") {
53
+ const loopsOut = loops.map((l) => loopJson(l, titles, valence, notes));
54
+ const payload = {
55
+ model: { id: modelId, name },
56
+ };
57
+ if (opts.hypothesis)
58
+ payload["dynamic_hypothesis"] = opts.hypothesis;
59
+ payload["graph"] = { nodes, edges };
60
+ payload["loops"] = loopsOut;
61
+ return { content: JSON.stringify(payload, null, 2), mime: "application/json", ext: "json" };
62
+ }
63
+ const mermaid = buildMermaid(nodes, edges);
64
+ if (fmt === "mermaid")
65
+ return { content: mermaid, mime: "text/plain", ext: "mmd" };
66
+ // Markdown
67
+ const md = [`# ${name}`, ""];
68
+ if (opts.hypothesis) {
69
+ const h = opts.hypothesis;
70
+ const problem = String(h["problem"] ?? "").trim();
71
+ const horizon = String(h["horizon"] ?? "").trim();
72
+ const narrative = String(h["narrative"] ?? "").trim();
73
+ if (problem || horizon || narrative) {
74
+ md.push("## Dynamic hypothesis", "");
75
+ if (problem)
76
+ md.push(`**Behavior of interest:** ${problem} `);
77
+ if (horizon)
78
+ md.push(`**Time horizon:** ${horizon}`);
79
+ if (problem || horizon)
80
+ md.push("");
81
+ if (narrative)
82
+ md.push(narrative, "");
83
+ }
84
+ }
85
+ md.push("```mermaid", mermaid, "```", "");
86
+ if (loops.length > 0) {
87
+ md.push("## Feedback loops", "");
88
+ for (const l of loops) {
89
+ const cycle = l.loop.join(" → ");
90
+ const key = loopNoteKey(l.loop, l.type);
91
+ const val = valence[key];
92
+ const tag = val === "virtuous" ? " _(virtuous cycle)_" : val === "vicious" ? " _(vicious cycle)_" : "";
93
+ const lead = [];
94
+ if (l.label)
95
+ lead.push(`**${l.label}**`);
96
+ const title = titles[key];
97
+ if (title && title.trim().length > 0)
98
+ lead.push(`**${title}**`);
99
+ const leadStr = lead.length > 0 ? lead.join(" · ") + " · " : "";
100
+ md.push(`- ${leadStr}**${l.type}** — ${cycle}${tag}`);
101
+ const note = notes[key];
102
+ if (note !== undefined) {
103
+ for (const ln of note.split("\n"))
104
+ md.push(ln.trim().length === 0 ? " >" : ` > ${ln}`);
105
+ }
106
+ }
107
+ md.push("");
108
+ }
109
+ return { content: md.join("\n"), mime: "text/markdown", ext: "md" };
110
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Deterministic Fruchterman-Reingold layout (circle seed, no RNG) — TypeScript
3
+ * port of `autoLayout` in `core/lib/cli/formats.dart`. Produces round, evenly
4
+ * spaced loops; an overlap-removal pass nudges only the node boxes that still
5
+ * collide. Identical inputs yield identical positions across CLI/app/plugin, so
6
+ * "Tidy" is consistent everywhere.
7
+ */
8
+ export interface LayoutNode {
9
+ id: string;
10
+ name: string;
11
+ kind?: string;
12
+ }
13
+ export interface LayoutEdge {
14
+ source: string;
15
+ target: string;
16
+ }
17
+ export declare function boxSize(name: string, kind?: string): [number, number];
18
+ /** Returns id -> [x, y]. */
19
+ export declare function autoLayout(nodes: LayoutNode[], edges: LayoutEdge[], cx?: number, cy?: number): Map<string, [number, number]>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Deterministic Fruchterman-Reingold layout (circle seed, no RNG) — TypeScript
3
+ * port of `autoLayout` in `core/lib/cli/formats.dart`. Produces round, evenly
4
+ * spaced loops; an overlap-removal pass nudges only the node boxes that still
5
+ * collide. Identical inputs yield identical positions across CLI/app/plugin, so
6
+ * "Tidy" is consistent everywhere.
7
+ */
8
+ const clampMin = (d, lo) => (d < lo ? lo : d);
9
+ export function boxSize(name, kind) {
10
+ const h = kind === "stock" ? 40 : 34;
11
+ const extra = kind === "flow" ? 40 : 36;
12
+ const w = Math.max(60, name.length * 7.2 + extra);
13
+ return [w, h];
14
+ }
15
+ /** Returns id -> [x, y]. */
16
+ export function autoLayout(nodes, edges, cx = 280, cy = 300) {
17
+ const ids = nodes.map((v) => v.id);
18
+ const n = ids.length;
19
+ const result = new Map();
20
+ if (n === 0)
21
+ return result;
22
+ if (n === 1) {
23
+ result.set(ids[0], [cx, cy]);
24
+ return result;
25
+ }
26
+ const idx = new Map();
27
+ ids.forEach((id, i) => idx.set(id, i));
28
+ const radius = Math.max(170, n * 34);
29
+ const pos = new Map();
30
+ for (let i = 0; i < n; i++) {
31
+ const a = (2 * Math.PI * i) / n;
32
+ pos.set(ids[i], [cx + radius * Math.cos(a), cy + radius * Math.sin(a)]);
33
+ }
34
+ const adj = [];
35
+ for (const e of edges) {
36
+ const s = String(e.source);
37
+ const t = String(e.target);
38
+ if (idx.has(s) && idx.has(t) && s !== t)
39
+ adj.push([s, t]);
40
+ }
41
+ const area = Math.pow(2.2 * radius, 2);
42
+ const k = 1.15 * Math.sqrt(area / n);
43
+ let temp = radius * 0.3;
44
+ for (let iter = 0; iter < 320; iter++) {
45
+ const disp = new Map();
46
+ for (const id of ids)
47
+ disp.set(id, [0, 0]);
48
+ for (let i = 0; i < n; i++) {
49
+ for (let j = i + 1; j < n; j++) {
50
+ const vi = ids[i];
51
+ const vj = ids[j];
52
+ const pi = pos.get(vi);
53
+ const pj = pos.get(vj);
54
+ const dx = pi[0] - pj[0];
55
+ const dy = pi[1] - pj[1];
56
+ const d = clampMin(Math.sqrt(dx * dx + dy * dy), 0.01);
57
+ const f = (k * k) / d;
58
+ const ux = dx / d;
59
+ const uy = dy / d;
60
+ const di = disp.get(vi);
61
+ const dj = disp.get(vj);
62
+ di[0] += ux * f;
63
+ di[1] += uy * f;
64
+ dj[0] -= ux * f;
65
+ dj[1] -= uy * f;
66
+ }
67
+ }
68
+ for (const [a, b] of adj) {
69
+ const pa = pos.get(a);
70
+ const pb = pos.get(b);
71
+ const dx = pa[0] - pb[0];
72
+ const dy = pa[1] - pb[1];
73
+ const d = clampMin(Math.sqrt(dx * dx + dy * dy), 0.01);
74
+ const f = (d * d) / k;
75
+ const ux = dx / d;
76
+ const uy = dy / d;
77
+ const da = disp.get(a);
78
+ const db = disp.get(b);
79
+ da[0] -= ux * f;
80
+ da[1] -= uy * f;
81
+ db[0] += ux * f;
82
+ db[1] += uy * f;
83
+ }
84
+ for (const id of ids) {
85
+ const dp = disp.get(id);
86
+ const p = pos.get(id);
87
+ const d = clampMin(Math.sqrt(dp[0] * dp[0] + dp[1] * dp[1]), 0.01);
88
+ p[0] += (dp[0] / d) * Math.min(d, temp);
89
+ p[1] += (dp[1] / d) * Math.min(d, temp);
90
+ }
91
+ temp *= 0.97;
92
+ }
93
+ // Overlap removal using real label box sizes.
94
+ const sizes = new Map();
95
+ for (const v of nodes)
96
+ sizes.set(v.id, boxSize(v.name, v.kind));
97
+ const margin = 18;
98
+ for (let iter = 0; iter < 120; iter++) {
99
+ let moved = false;
100
+ for (let i = 0; i < n; i++) {
101
+ for (let j = i + 1; j < n; j++) {
102
+ const vi = ids[i];
103
+ const vj = ids[j];
104
+ const si = sizes.get(vi);
105
+ const sj = sizes.get(vj);
106
+ const pi = pos.get(vi);
107
+ const pj = pos.get(vj);
108
+ const dx = pj[0] - pi[0];
109
+ const dy = pj[1] - pi[1];
110
+ const ox = (si[0] + sj[0]) / 2 + margin - Math.abs(dx);
111
+ const oy = (si[1] + sj[1]) / 2 + margin - Math.abs(dy);
112
+ if (ox > 0 && oy > 0) {
113
+ if (ox <= oy) {
114
+ const push = (ox / 2) * (dx < 0 ? -1 : 1);
115
+ pi[0] -= push;
116
+ pj[0] += push;
117
+ }
118
+ else {
119
+ const push = (oy / 2) * (dy < 0 ? -1 : 1);
120
+ pi[1] -= push;
121
+ pj[1] += push;
122
+ }
123
+ moved = true;
124
+ }
125
+ }
126
+ }
127
+ if (!moved)
128
+ break;
129
+ }
130
+ let mx = 0;
131
+ let my = 0;
132
+ for (const p of pos.values()) {
133
+ mx += p[0];
134
+ my += p[1];
135
+ }
136
+ mx /= n;
137
+ my /= n;
138
+ for (const [id, p] of pos) {
139
+ result.set(id, [Number((p[0] - mx + cx).toFixed(1)), Number((p[1] - my + cy).toFixed(1))]);
140
+ }
141
+ return result;
142
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Pure in-memory graph built from parsed vault notes — TypeScript port of
3
+ * `core/lib/graph/loop_graph.dart`.
4
+ *
5
+ * Loop detection excludes `indirect` (dashed) links and classifies each simple
6
+ * directed cycle by the product of its link polarities: even number of `-` -> R
7
+ * (reinforcing), odd -> B (balancing). `delay` is ignored. Enumeration
8
+ * canonicalizes each cycle to start at its minimum node id and only extends to
9
+ * ids >= the start, yielding each cycle once with no artificial depth bound.
10
+ */
11
+ import { DetectedLoop, VariableFile } from "./types";
12
+ /** One adjacency edge as seen by a trace. */
13
+ export interface TracedEdge {
14
+ to: string;
15
+ polarity: number;
16
+ indirect: boolean;
17
+ confidence?: number;
18
+ basis?: string;
19
+ }
20
+ export interface VarMetrics {
21
+ inDegree: number;
22
+ outDegree: number;
23
+ loopCount: number;
24
+ }
25
+ /**
26
+ * Deterministic loop labels: reinforcing -> R1,R2,… and balancing -> B1,B2,…,
27
+ * ordered within each type by case-insensitive sorted variable names. Returns a
28
+ * map from `DetectedLoop.key` to its label.
29
+ */
30
+ export declare function labelLoopsByKey(loops: DetectedLoop[], name: (id: string) => string): Map<string, string>;
31
+ export declare class LoopGraph {
32
+ private readonly nodes;
33
+ constructor(notes: Iterable<VariableFile>);
34
+ get allNodes(): VariableFile[];
35
+ node(id: string): VariableFile | undefined;
36
+ /** Full causal adjacency including `indirect` links, with evidence carries. */
37
+ adjacency(reverse?: boolean): Map<string, TracedEdge[]>;
38
+ /** Direct (loop-relevant) adjacency: source -> [target, polaritySign]. */
39
+ private directAdjacency;
40
+ /** All simple directed cycles, R/B classified, deduped to one per node-set. */
41
+ detectLoops(): DetectedLoop[];
42
+ /**
43
+ * The Shortest Independent Loop Set (SILS): the minimum-weight basis of the
44
+ * cycle space, found by a greedy GF(2) XOR-basis pass over cycles sorted by
45
+ * length (ties broken on `key`). Covers every loop edge with a minimal,
46
+ * independent set.
47
+ */
48
+ shortestIndependentLoopSet(): DetectedLoop[];
49
+ /** Degree (in/out over ALL links) + loop participation per variable. */
50
+ metrics(): Map<string, VarMetrics>;
51
+ }
Binary file
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The resolved-map key for a feedback loop: `<R|B>:<sorted unique member
3
+ * labels>`. This is the key every UI consumer indexes loop notes by; it is NOT
4
+ * the loop's on-disk identity (that lives in each `Loops/*.md` frontmatter as
5
+ * the ordered member ids — see `loopNote.ts`). Mirrors Dart `fmt.loopNoteKey`.
6
+ *
7
+ * Pure and dependency-free so both the engine (`nativeEngine`) and the view
8
+ * (`view/loopKeys`) derive the key from one definition instead of each carrying
9
+ * its own copy.
10
+ */
11
+ export declare function loopKey(labels: string[], type: string): string;
12
+ /**
13
+ * Human-readable, non-link echo for a loop note's `loop:` frontmatter field:
14
+ * `<R|B> · <sorted unique labels joined by " | ">`. Same membership rule as
15
+ * `loopKey` (dedupe + sort) but rendered for humans — it deliberately avoids a
16
+ * leading `scheme:` so Obsidian's property view does not show it as a (dead)
17
+ * external link. Display only; NEVER an identity/lookup/export/API key (that is
18
+ * `loopKey`). Mirrors Dart `fmt.loopEchoLabel`.
19
+ *
20
+ * The Set dedupe absorbs a closed-cycle duplicate, so no explicit `first==last`
21
+ * trim is needed. Keep this symmetric with the Dart twin: do not add a trim to
22
+ * only one side — divergent membership handling would break byte parity.
23
+ */
24
+ export declare function loopEchoLabel(labels: string[], type: string): string;