@neoloopy/cld-canvas 0.1.1 → 0.1.3

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 (3) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -76
  3. package/package.json +21 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 neoloopy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,14 +2,16 @@
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
+ **This README is ordered easy hard** start at the top with the one-tag React
9
+ drop-in and go only as deep as you need:
8
10
 
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.
11
+ 1. [React, in one tag](#1-react-in-one-tag-easiest) easiest
12
+ 2. [Build and render it yourself](#2-build-and-render-it-yourself) — any framework / full control
13
+ 3. [The engine](#3-the-engine) create, edit, and load models
14
+ 4. [Exporting](#4-exporting) and [analysis](#5-analysis-helpers) advanced
13
15
 
14
16
  ## Install
15
17
 
@@ -17,30 +19,280 @@ Obsidian, React, Vue, or a server. It gives you two pieces:
17
19
  npm install @neoloopy/cld-canvas
18
20
  ```
19
21
 
20
- If you use `NativeEngine`, provide a YAML parser for note frontmatter. The
21
- package does not bundle one:
22
+ If you use the engine (`NativeEngine`), also install a YAML parser for note
23
+ frontmatter — the package does not bundle one:
22
24
 
23
25
  ```sh
24
26
  npm install yaml
25
27
  ```
26
28
 
27
- ## Requirements
29
+ ## 1. React, in one tag (easiest)
30
+
31
+ Drop the [`<CldCanvas>` component](#the-cldcanvas-component) below into your
32
+ project, hand it a spec, and you're done:
33
+
34
+ ```tsx
35
+ import { CldCanvas } from "./CldCanvas";
36
+
37
+ export default function App() {
38
+ return (
39
+ <CldCanvas
40
+ spec={{
41
+ variables: [
42
+ { label: "Users", type: "stock" },
43
+ { label: "Word of mouth" },
44
+ { label: "Signups" },
45
+ ],
46
+ links: [
47
+ { from: "Users", to: "Word of mouth", polarity: "+" },
48
+ { from: "Word of mouth", to: "Signups", polarity: "+" },
49
+ { from: "Signups", to: "Users", polarity: "+" },
50
+ ],
51
+ }}
52
+ />
53
+ );
54
+ }
55
+ ```
28
56
 
29
- - TypeScript or JavaScript with ESM imports
30
- - `ES2020` runtime support
31
- - A `CanvasRenderingContext2D` if you use the painter
57
+ It builds the model, detects the feedback loops, frames the diagram, and gives
58
+ you pan (drag) + zoom (wheel) — with no surrounding CSS required. Props: `spec`
59
+ (required), `theme` (`"light"` | `"dark"` | a custom `Theme`), `interactive`
60
+ (default `true`), `height` (number px or CSS string, default `400`), plus
61
+ `onReady(graph)` / `onError(error)` callbacks.
32
62
 
33
- ## Quick Start
63
+ ### The `<CldCanvas>` component
34
64
 
35
- ```ts
65
+ Copy this one file into your project; its only dependencies are
66
+ `@neoloopy/cld-canvas` and `yaml`.
67
+
68
+ ```tsx
69
+ // CldCanvas.tsx — a drop-in React wrapper around @neoloopy/cld-canvas.
70
+ import { useEffect, useMemo, useRef } from "react";
71
+ import type { CSSProperties } from "react";
36
72
  import { parse } from "yaml";
37
73
  import {
38
74
  MemoryStorage,
39
75
  NativeEngine,
76
+ Camera,
77
+ SceneCache,
78
+ paint,
79
+ LIGHT,
80
+ DARK,
40
81
  } from "@neoloopy/cld-canvas";
82
+ import type { BuildSpec, GraphView, PaintUi, Theme } from "@neoloopy/cld-canvas";
83
+
84
+ export interface CldCanvasProps {
85
+ /** The causal-loop diagram to render: variables + polarized links. */
86
+ spec: BuildSpec;
87
+ /** Color tokens — "light" / "dark", or a custom Theme. Default "light". */
88
+ theme?: "light" | "dark" | Theme;
89
+ /** Pan (drag) + zoom (wheel). Default true. */
90
+ interactive?: boolean;
91
+ /**
92
+ * Canvas height. A number is pixels (default 400); a string is used verbatim,
93
+ * so pass "100%" / "60vh" to fill a sized parent. Width always fills the
94
+ * parent, so `<CldCanvas spec={...} />` works with no surrounding CSS.
95
+ */
96
+ height?: number | string;
97
+ className?: string;
98
+ style?: CSSProperties;
99
+ /** Called with the loaded graph (detected loops, badge labels) after a build. */
100
+ onReady?: (graph: GraphView) => void;
101
+ /** Called if the spec is invalid or the model fails to build. */
102
+ onError?: (error: Error) => void;
103
+ }
104
+
105
+ // Interaction fields the painter wants every frame; we drive only pan/zoom, so
106
+ // the rest stay inert.
107
+ const PAINT_DEFAULTS = {
108
+ selectedNodeId: null,
109
+ selectedEdgeId: null,
110
+ selectedLoopKey: null,
111
+ liveNodeIds: new Set<string>(),
112
+ linkPreview: null,
113
+ connectNodeId: null,
114
+ loopHighlight: null,
115
+ pulsePhase: 0,
116
+ flowPhase: 0,
117
+ } satisfies Omit<PaintUi, "cssWidth" | "cssHeight" | "dpr">;
118
+
119
+ // The full engine path: an in-memory vault, build the model, load the detected
120
+ // graph. NativeEngine needs a YAML parser for note frontmatter (yaml).
121
+ async function buildGraph(spec: BuildSpec): Promise<GraphView> {
122
+ const engine = new NativeEngine(new MemoryStorage(), parse, {
123
+ modelsRoot: "Models",
124
+ });
125
+ const model = await engine.createModel("model");
126
+ await engine.buildModel(model.folder, spec); // layout defaults to true
127
+ return engine.loadGraph(model.folder);
128
+ }
129
+
130
+ function resolveTheme(t: CldCanvasProps["theme"]): Theme {
131
+ if (t === "dark") return DARK;
132
+ if (t === "light" || t == null) return LIGHT;
133
+ return t;
134
+ }
135
+
136
+ export function CldCanvas({
137
+ spec,
138
+ theme = "light",
139
+ interactive = true,
140
+ height = 400,
141
+ className,
142
+ style,
143
+ onReady,
144
+ onError,
145
+ }: CldCanvasProps) {
146
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
147
+ const camera = useRef(new Camera()).current;
148
+ const sceneCache = useRef(new SceneCache()).current;
149
+ const graphRef = useRef<GraphView | null>(null);
150
+ const fitted = useRef(false);
151
+ const drag = useRef<{ x: number; y: number } | null>(null);
152
+
153
+ const resolved = useMemo(() => resolveTheme(theme), [theme]);
154
+
155
+ // Mutable mirrors so the build effect and the once-registered listeners read
156
+ // current values without re-subscribing.
157
+ const themeRef = useRef(resolved);
158
+ themeRef.current = resolved;
159
+ const interactiveRef = useRef(interactive);
160
+ interactiveRef.current = interactive;
161
+ const onReadyRef = useRef(onReady);
162
+ onReadyRef.current = onReady;
163
+ const onErrorRef = useRef(onError);
164
+ onErrorRef.current = onError;
165
+
166
+ const render = () => {
167
+ const canvas = canvasRef.current;
168
+ const ctx = canvas?.getContext("2d");
169
+ if (!canvas || !ctx) return;
170
+
171
+ const dpr = window.devicePixelRatio || 1;
172
+ const width = canvas.clientWidth;
173
+ const height = canvas.clientHeight;
174
+ const bw = Math.max(1, Math.floor(width * dpr));
175
+ const bh = Math.max(1, Math.floor(height * dpr));
176
+ if (canvas.width !== bw) canvas.width = bw;
177
+ if (canvas.height !== bh) canvas.height = bh;
178
+
179
+ const scene = sceneCache.build(graphRef.current, new Map(), new Map());
180
+ ctx.clearRect(0, 0, bw, bh);
181
+ if (!scene) return;
182
+
183
+ if (!fitted.current && sceneCache.fit(camera, width, height)) {
184
+ fitted.current = true; // frame once per spec; pan/zoom persists after
185
+ }
186
+
187
+ paint(ctx, scene, camera, themeRef.current, {
188
+ ...PAINT_DEFAULTS,
189
+ cssWidth: width,
190
+ cssHeight: height,
191
+ dpr,
192
+ });
193
+ };
194
+ const renderRef = useRef(render);
195
+ renderRef.current = render;
196
+
197
+ // Rebuild only when the spec's content changes (not on every parent render).
198
+ const specKey = useMemo(() => JSON.stringify(spec), [spec]);
199
+ useEffect(() => {
200
+ let cancelled = false;
201
+ buildGraph(spec)
202
+ .then((graph) => {
203
+ if (cancelled) return;
204
+ graphRef.current = graph;
205
+ fitted.current = false;
206
+ renderRef.current();
207
+ onReadyRef.current?.(graph);
208
+ })
209
+ .catch((e) => {
210
+ if (!cancelled) {
211
+ onErrorRef.current?.(e instanceof Error ? e : new Error(String(e)));
212
+ }
213
+ });
214
+ return () => {
215
+ cancelled = true;
216
+ };
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps
218
+ }, [specKey]);
219
+
220
+ // Repaint (no refit) when the theme changes.
221
+ useEffect(() => {
222
+ renderRef.current();
223
+ }, [resolved]);
224
+
225
+ // Register resize + wheel once; both read live state via refs.
226
+ useEffect(() => {
227
+ const canvas = canvasRef.current;
228
+ if (!canvas) return;
229
+
230
+ const ro = new ResizeObserver(() => renderRef.current());
231
+ ro.observe(canvas);
232
+
233
+ const onWheel = (e: WheelEvent) => {
234
+ if (!interactiveRef.current) return;
235
+ e.preventDefault();
236
+ camera.zoomAt(e.offsetX, e.offsetY, Math.exp(-e.deltaY * 0.0015));
237
+ renderRef.current();
238
+ };
239
+ canvas.addEventListener("wheel", onWheel, { passive: false });
240
+
241
+ return () => {
242
+ ro.disconnect();
243
+ canvas.removeEventListener("wheel", onWheel);
244
+ };
245
+ }, [camera]);
246
+
247
+ return (
248
+ <canvas
249
+ ref={canvasRef}
250
+ className={className}
251
+ style={{
252
+ display: "block",
253
+ width: "100%",
254
+ height: typeof height === "number" ? `${height}px` : height,
255
+ touchAction: "none",
256
+ background: resolved.paper,
257
+ cursor: interactive ? "grab" : "default",
258
+ ...style,
259
+ }}
260
+ onPointerDown={(e) => {
261
+ if (!interactive) return;
262
+ e.currentTarget.setPointerCapture(e.pointerId);
263
+ drag.current = { x: e.clientX, y: e.clientY };
264
+ }}
265
+ onPointerMove={(e) => {
266
+ if (!drag.current) return;
267
+ camera.panBy(e.clientX - drag.current.x, e.clientY - drag.current.y);
268
+ drag.current = { x: e.clientX, y: e.clientY };
269
+ renderRef.current();
270
+ }}
271
+ onPointerUp={(e) => {
272
+ drag.current = null;
273
+ e.currentTarget.releasePointerCapture(e.pointerId);
274
+ }}
275
+ />
276
+ );
277
+ }
278
+ ```
279
+
280
+ The same wrapper shape works in Vue, Svelte, or vanilla DOM — only the lifecycle
281
+ hook around `buildGraph` + `paint` changes.
282
+
283
+ ## 2. Build and render it yourself
284
+
285
+ Not on React, or want full control? The wrapper above is just two steps: build
286
+ a graph with the engine, then paint it. You need an `ES2020` ESM runtime and a
287
+ `CanvasRenderingContext2D`.
288
+
289
+ **Step 1 — build a model and load its graph:**
290
+
291
+ ```ts
292
+ import { parse } from "yaml";
293
+ import { MemoryStorage, NativeEngine } from "@neoloopy/cld-canvas";
41
294
 
42
- const storage = new MemoryStorage();
43
- const engine = new NativeEngine(storage, parse, {
295
+ const engine = new NativeEngine(new MemoryStorage(), parse, {
44
296
  modelsRoot: "Models",
45
297
  });
46
298
 
@@ -59,24 +311,13 @@ await engine.buildModel(model.folder, {
59
311
  ],
60
312
  });
61
313
 
62
- const graph = await engine.loadGraph(model.folder);
63
-
64
- console.log(graph.nodes.map((node) => node.label));
65
- console.log(graph.loops.length);
314
+ const graph = await engine.loadGraph(model.folder); // detects R/B loops
66
315
  ```
67
316
 
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`.
317
+ **Step 2 — paint the graph to a canvas:**
72
318
 
73
319
  ```ts
74
- import {
75
- Camera,
76
- LIGHT,
77
- SceneCache,
78
- paint,
79
- } from "@neoloopy/cld-canvas";
320
+ import { Camera, LIGHT, SceneCache, paint } from "@neoloopy/cld-canvas";
80
321
 
81
322
  const canvas = document.querySelector("canvas")!;
82
323
  const ctx = canvas.getContext("2d")!;
@@ -111,47 +352,17 @@ if (scene) {
111
352
  }
112
353
  ```
113
354
 
114
- Use `Camera.panBy`, `Camera.zoomAt`, `Camera.setScaleAt`, and
115
- `Camera.centerOn` to wire your own pointer, wheel, trackpad, minimap, or toolbar
116
- controls.
355
+ Wire `Camera.panBy`, `Camera.zoomAt`, `Camera.setScaleAt`, and `Camera.centerOn`
356
+ to your own pointer, wheel, trackpad, minimap, or toolbar controls. Canvas
357
+ rendering is stateless: keep selection, hover, and animation clocks in your app
358
+ state and pass them into `SceneCache` / `paint`.
117
359
 
118
- ## Engine Concepts
360
+ ## 3. The engine
119
361
 
120
- ### Storage
362
+ Past rendering, `NativeEngine` is a full create/edit/load API for neoloopy
363
+ models.
121
364
 
122
- `NativeEngine` works against a small filesystem-like `VaultStorage` interface:
123
-
124
- ```ts
125
- interface VaultStorage {
126
- exists(path: string): Promise<boolean>;
127
- read(path: string): Promise<string>;
128
- write(path: string, data: string): Promise<void>;
129
- remove(path: string): Promise<void>;
130
- mkdirs(path: string): Promise<void>;
131
- rmdir(path: string): Promise<void>;
132
- move(from: string, to: string): Promise<void>;
133
- list(path: string): Promise<{ files: string[]; folders: string[] }>;
134
- }
135
- ```
136
-
137
- Use `MemoryStorage` for tests, demos, and server-side transforms. In production,
138
- adapt this interface to your host filesystem, browser persistence layer.
139
-
140
- All paths are vault-relative and use `/` separators.
141
-
142
- ### Model Format
143
-
144
- A model is stored as:
145
-
146
- - `model.json` for model metadata and viewport
147
- - `Nodes/*.md` for variable notes with YAML frontmatter and Markdown body
148
- - `Loops/*.md` for feedback loop notes
149
- - optional system notes such as `System.md`
150
-
151
- The engine preserves unknown frontmatter keys so other neoloopy tools can add
152
- metadata without being clobbered.
153
-
154
- ### Main Engine Methods
365
+ ### Engine methods
155
366
 
156
367
  ```ts
157
368
  const model = await engine.createModel("Model name");
@@ -179,7 +390,8 @@ await engine.relayout(model.folder);
179
390
  await engine.setViewport(model.folder, { x: 0, y: 0, zoom: 1 });
180
391
  ```
181
392
 
182
- Bulk build is useful when importing generated or external CLD specs:
393
+ Bulk build (used above) is the fastest path for importing generated or external
394
+ CLD specs:
183
395
 
184
396
  ```ts
185
397
  await engine.buildModel(model.folder, {
@@ -195,7 +407,43 @@ await engine.buildModel(model.folder, {
195
407
  });
196
408
  ```
197
409
 
198
- ## Exporting
410
+ ### Storage
411
+
412
+ `NativeEngine` works against a small filesystem-like `VaultStorage` interface:
413
+
414
+ ```ts
415
+ interface VaultStorage {
416
+ exists(path: string): Promise<boolean>;
417
+ read(path: string): Promise<string>;
418
+ write(path: string, data: string): Promise<void>;
419
+ remove(path: string): Promise<void>;
420
+ mkdirs(path: string): Promise<void>;
421
+ rmdir(path: string): Promise<void>;
422
+ move(from: string, to: string): Promise<void>;
423
+ list(path: string): Promise<{ files: string[]; folders: string[] }>;
424
+ }
425
+ ```
426
+
427
+ Use `MemoryStorage` for tests, demos, and server-side transforms (it is not
428
+ persistent). In production, adapt this interface to your host filesystem or
429
+ browser persistence layer. All paths are vault-relative and use `/` separators.
430
+ `move` should preserve or update links when your host supports it — the Obsidian
431
+ adapter routes folder moves through Obsidian's file manager so wikilinks remain
432
+ valid.
433
+
434
+ ### Model format
435
+
436
+ A model is stored as:
437
+
438
+ - `model.json` for model metadata and viewport
439
+ - `Nodes/*.md` for variable notes with YAML frontmatter and Markdown body
440
+ - `Loops/*.md` for feedback loop notes
441
+ - optional system notes such as `System.md`
442
+
443
+ The engine preserves unknown frontmatter keys so other neoloopy tools can add
444
+ metadata without being clobbered.
445
+
446
+ ## 4. Exporting
199
447
 
200
448
  `NativeEngine.export` supports `json`, `mermaid`, and `markdown`.
201
449
 
@@ -213,7 +461,7 @@ You can also use the lower-level renderer functions directly:
213
461
  import { buildMermaid, render } from "@neoloopy/cld-canvas";
214
462
  ```
215
463
 
216
- ## Analysis Helpers
464
+ ## 5. Analysis helpers
217
465
 
218
466
  ```ts
219
467
  import { LoopGraph, endogeneity } from "@neoloopy/cld-canvas";
@@ -224,7 +472,7 @@ const metrics = loopGraph.metrics();
224
472
  const summary = endogeneity(graph.nodes, loops);
225
473
  ```
226
474
 
227
- ## What Is Exported
475
+ ## What is exported
228
476
 
229
477
  The package exports the public modules from `src/index.ts`, including:
230
478
 
@@ -236,13 +484,10 @@ The package exports the public modules from `src/index.ts`, including:
236
484
  - Exporters: `render`, `buildMermaid`, `loopNoteKey`
237
485
  - Analysis: `endogeneity`
238
486
 
239
- ## Notes for Integrators
487
+ ## Notes for integrators
240
488
 
241
489
  - The package is ESM-only. Use `import`, not `require`.
242
490
  - The engine is asynchronous because real storage adapters usually perform I/O.
243
- - `move` should preserve or update links when your host platform supports it.
244
- The Obsidian adapter, for example, should route folder moves through Obsidian's
245
- file manager so wikilinks remain valid.
246
491
  - Canvas rendering is stateless. Keep selection, hover, animation clocks, bow
247
492
  caches, and badge overrides in your application state and pass them to
248
493
  `SceneCache` or `paint`.
package/package.json CHANGED
@@ -1,14 +1,34 @@
1
1
  {
2
2
  "name": "@neoloopy/cld-canvas",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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
+ "keywords": [
8
+ "cld",
9
+ "causal-loop-diagram",
10
+ "systems-thinking",
11
+ "system-dynamics",
12
+ "feedback-loops",
13
+ "canvas",
14
+ "diagram",
15
+ "visualization",
16
+ "graph",
17
+ "neoloopy"
18
+ ],
7
19
  "type": "module",
8
20
  "main": "dist/index.js",
9
21
  "module": "dist/index.js",
10
22
  "types": "dist/index.d.ts",
11
23
  "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
24
+ "sideEffects": false,
12
25
  "files": ["dist"],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/frozenabe/neoloopy-obsidian.git",
29
+ "directory": "packages/cld-canvas"
30
+ },
31
+ "homepage": "https://github.com/frozenabe/neoloopy-obsidian/tree/main/packages/cld-canvas#readme",
32
+ "bugs": { "url": "https://github.com/frozenabe/neoloopy-obsidian/issues" },
13
33
  "scripts": { "build": "tsc -p tsconfig.json" }
14
34
  }