@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,55 @@
1
+ /**
2
+ * Subsystem-link resolution + parent-anchor derivation — the TypeScript mirror
3
+ * of `core/lib/vault/subsystem_link.dart`. A node's `subsystem` field holds a
4
+ * relative wikilink `[[../<dir>/System|<alias>]]` pointing DOWN into a child
5
+ * model; deriving parents is the inverse — scanning other models for nodes that
6
+ * point at us. Pure: IO is injected via `readNodes` so it stays unit-testable.
7
+ */
8
+ /** Parse `[[../<dir>/System|<alias>]]` into its folder-basename hint + alias. */
9
+ export function parseSubsystemLink(raw) {
10
+ const trimmed = (raw ?? "").trim();
11
+ if (!trimmed)
12
+ return { dir: null, alias: null };
13
+ let body = trimmed;
14
+ if (body.startsWith("[[") && body.endsWith("]]"))
15
+ body = body.slice(2, -2);
16
+ const bar = body.indexOf("|");
17
+ const alias = bar >= 0 ? body.slice(bar + 1).trim() : null;
18
+ const target = (bar >= 0 ? body.slice(0, bar) : body).trim();
19
+ const dir = target
20
+ .split("/")
21
+ .filter((s) => s && s !== ".." && s.toLowerCase() !== "system")
22
+ .pop() ?? null;
23
+ return { dir, alias };
24
+ }
25
+ /** Does a stored subsystem link resolve to `model`? Folder basename, then name. */
26
+ export function linkPointsToModel(raw, model) {
27
+ const { dir, alias } = parseSubsystemLink(raw);
28
+ if (dir == null && alias == null)
29
+ return false;
30
+ const base = model.folder.split("/").filter(Boolean).pop() ?? model.folder;
31
+ return ((dir != null && base.toLowerCase() === dir.toLowerCase()) ||
32
+ (alias != null && model.name.toLowerCase() === alias.toLowerCase()));
33
+ }
34
+ /** Scan `others` for nodes whose `subsystem` link points back at `current`. */
35
+ export async function deriveParentAnchors(current, others, readNodes) {
36
+ const out = [];
37
+ for (const m of others) {
38
+ if (m.folder === current.folder)
39
+ continue;
40
+ const nodes = await readNodes(m.folder);
41
+ for (const n of nodes) {
42
+ if (!n.subsystem)
43
+ continue;
44
+ if (linkPointsToModel(n.subsystem, current)) {
45
+ out.push({
46
+ modelFolder: m.folder,
47
+ modelName: m.name,
48
+ anchorVarId: n.id,
49
+ anchorVarLabel: n.label || n.id,
50
+ });
51
+ }
52
+ }
53
+ }
54
+ return out;
55
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * File-format domain types for the local-first vault — a TypeScript port of
3
+ * `core/lib/vault/vault_model.dart` and the loop types from
4
+ * `core/lib/graph/loop_graph.dart`.
5
+ *
6
+ * These mirror the on-disk shape: a model is a folder of variable notes
7
+ * (`<id>.md`, YAML frontmatter + Markdown body) plus a `model.json` manifest.
8
+ * Timestamps are stored as UTC ISO-8601 strings (the codec normalizes on parse,
9
+ * matching Dart's `DateTime.toUtc().toIso8601String()`; sub-millisecond
10
+ * precision is not preserved, but timestamps are excluded from the content
11
+ * signature so this never causes a false external-edit flag).
12
+ */
13
+ export type VarType = "stock" | "flow" | "auxiliary";
14
+ export declare function varTypeFrom(s: string | null | undefined): VarType;
15
+ export declare function varTypeName(t: VarType): string;
16
+ /** Loop classification. Order matters: matches Dart `enum LoopType`. */
17
+ export declare enum LoopType {
18
+ reinforcing = 0,
19
+ balancing = 1
20
+ }
21
+ /**
22
+ * A directed causal link, stored **outgoing** on the source variable note.
23
+ * `weight`/`curvature` are cosmetic fidelity carries; `confidence`/`basis` are
24
+ * evidence carries. All are omitted when unset so plain notes stay clean and
25
+ * pre-existing vaults round-trip byte-identically.
26
+ */
27
+ export interface VaultLink {
28
+ to: string;
29
+ polarity: "+" | "-";
30
+ delay: boolean;
31
+ indirect: boolean;
32
+ nonlinear: boolean;
33
+ weight?: number;
34
+ curvature?: number;
35
+ confidence?: number;
36
+ basis?: string;
37
+ }
38
+ /** Clamp a raw confidence to [0,1]; null/NaN -> undefined (unspecified). */
39
+ export declare function normalizeConfidence(v: unknown): number | undefined;
40
+ /** Trim a raw basis; empty -> undefined. */
41
+ export declare function normalizeBasis(v: unknown): string | undefined;
42
+ export declare function linkFromMap(m: Record<string, unknown>): VaultLink;
43
+ /**
44
+ * Ordered map for YAML emission (only meaningful keys, in the exact order and
45
+ * with the exact inclusion rules of Dart `VaultLink.toMap`).
46
+ */
47
+ export declare function linkToMap(l: VaultLink): Array<[string, string | number | boolean]>;
48
+ /** One variable note: structured frontmatter + free Markdown body. */
49
+ export interface VariableFile {
50
+ id: string;
51
+ type: VarType;
52
+ label: string;
53
+ group?: string;
54
+ claLayer?: string;
55
+ shared?: string;
56
+ x: number;
57
+ y: number;
58
+ links: VaultLink[];
59
+ body: string;
60
+ /** Unknown frontmatter keys, preserved verbatim on write (format rule §3). */
61
+ extra: Record<string, unknown>;
62
+ tags: string[];
63
+ status?: string;
64
+ created?: string;
65
+ modified?: string;
66
+ rev: number;
67
+ source?: string;
68
+ reviewed?: string;
69
+ reviewedBy?: string;
70
+ h?: string;
71
+ subsystem?: string;
72
+ }
73
+ export declare function emptyVariable(id: string, label?: string): VariableFile;
74
+ export interface Viewport {
75
+ x: number;
76
+ y: number;
77
+ zoom: number;
78
+ }
79
+ export declare function viewportFromMap(m: Record<string, unknown> | null | undefined): Viewport;
80
+ /** Model-level metadata (`model.json`). */
81
+ export interface ModelManifest {
82
+ id: string;
83
+ name: string;
84
+ schemaVersion: number;
85
+ viewport: Viewport;
86
+ created: string;
87
+ modified: string;
88
+ folder?: string;
89
+ order: number;
90
+ extra: Record<string, unknown>;
91
+ }
92
+ /** Normalize a timestamp value to UTC ISO; undefined-safe. */
93
+ export declare function toUtcIso(v: unknown): string | undefined;
94
+ export declare function manifestFromJson(j: Record<string, unknown>): ModelManifest;
95
+ export declare function manifestToJson(m: ModelManifest): Record<string, unknown>;
96
+ /** A detected feedback loop: variable ids in cycle order + R/B classification. */
97
+ export declare class DetectedLoop {
98
+ readonly nodeIds: string[];
99
+ readonly type: LoopType;
100
+ constructor(nodeIds: string[], type: LoopType);
101
+ /** Stable identity = type + the sorted unique node ids (one badge per loop). */
102
+ get key(): string;
103
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * File-format domain types for the local-first vault — a TypeScript port of
3
+ * `core/lib/vault/vault_model.dart` and the loop types from
4
+ * `core/lib/graph/loop_graph.dart`.
5
+ *
6
+ * These mirror the on-disk shape: a model is a folder of variable notes
7
+ * (`<id>.md`, YAML frontmatter + Markdown body) plus a `model.json` manifest.
8
+ * Timestamps are stored as UTC ISO-8601 strings (the codec normalizes on parse,
9
+ * matching Dart's `DateTime.toUtc().toIso8601String()`; sub-millisecond
10
+ * precision is not preserved, but timestamps are excluded from the content
11
+ * signature so this never causes a false external-edit flag).
12
+ */
13
+ export function varTypeFrom(s) {
14
+ return s === "stock" || s === "flow" ? s : "auxiliary";
15
+ }
16
+ export function varTypeName(t) {
17
+ return t;
18
+ }
19
+ /** Loop classification. Order matters: matches Dart `enum LoopType`. */
20
+ export var LoopType;
21
+ (function (LoopType) {
22
+ LoopType[LoopType["reinforcing"] = 0] = "reinforcing";
23
+ LoopType[LoopType["balancing"] = 1] = "balancing";
24
+ })(LoopType || (LoopType = {}));
25
+ /** Clamp a raw confidence to [0,1]; null/NaN -> undefined (unspecified). */
26
+ export function normalizeConfidence(v) {
27
+ const d = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
28
+ if (d === null || Number.isNaN(d))
29
+ return undefined;
30
+ return d < 0 ? 0 : d > 1 ? 1 : d;
31
+ }
32
+ /** Trim a raw basis; empty -> undefined. */
33
+ export function normalizeBasis(v) {
34
+ if (v === null || v === undefined)
35
+ return undefined;
36
+ const s = String(v).trim();
37
+ return s.length === 0 ? undefined : s;
38
+ }
39
+ export function linkFromMap(m) {
40
+ const pol = m["polarity"];
41
+ return {
42
+ to: String(m["to"]),
43
+ polarity: pol === "-" || pol === -1 ? "-" : "+",
44
+ delay: m["delay"] === true,
45
+ indirect: m["indirect"] === true,
46
+ nonlinear: m["nonlinear"] === true,
47
+ weight: typeof m["weight"] === "number" ? Math.trunc(m["weight"]) : undefined,
48
+ curvature: typeof m["curvature"] === "number" ? (m["curvature"]) : undefined,
49
+ confidence: normalizeConfidence(m["confidence"]),
50
+ basis: normalizeBasis(m["basis"]),
51
+ };
52
+ }
53
+ /**
54
+ * Ordered map for YAML emission (only meaningful keys, in the exact order and
55
+ * with the exact inclusion rules of Dart `VaultLink.toMap`).
56
+ */
57
+ export function linkToMap(l) {
58
+ const out = [
59
+ ["to", l.to],
60
+ ["polarity", l.polarity],
61
+ ["delay", l.delay],
62
+ ["indirect", l.indirect],
63
+ ["nonlinear", l.nonlinear],
64
+ ];
65
+ if (l.weight !== undefined && l.weight !== 0)
66
+ out.push(["weight", l.weight]);
67
+ if (l.curvature !== undefined)
68
+ out.push(["curvature", l.curvature]);
69
+ if (l.confidence !== undefined)
70
+ out.push(["confidence", l.confidence]);
71
+ if (l.basis !== undefined && l.basis.length > 0)
72
+ out.push(["basis", l.basis]);
73
+ return out;
74
+ }
75
+ export function emptyVariable(id, label = "") {
76
+ return {
77
+ id,
78
+ type: "auxiliary",
79
+ label,
80
+ x: 0,
81
+ y: 0,
82
+ links: [],
83
+ body: "",
84
+ extra: {},
85
+ tags: [],
86
+ rev: 0,
87
+ };
88
+ }
89
+ export function viewportFromMap(m) {
90
+ const num = (v, d) => (typeof v === "number" ? v : d);
91
+ return {
92
+ x: num(m?.["x"], 0),
93
+ y: num(m?.["y"], 0),
94
+ zoom: num(m?.["zoom"], 1),
95
+ };
96
+ }
97
+ const MANIFEST_KNOWN = new Set([
98
+ "id",
99
+ "name",
100
+ "schemaVersion",
101
+ "viewport",
102
+ "created",
103
+ "modified",
104
+ "folder",
105
+ "order",
106
+ ]);
107
+ /** Normalize a timestamp value to UTC ISO; undefined-safe. */
108
+ export function toUtcIso(v) {
109
+ if (v === null || v === undefined || v === "")
110
+ return undefined;
111
+ const d = new Date(String(v));
112
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
113
+ }
114
+ export function manifestFromJson(j) {
115
+ const ts = (v) => toUtcIso(v) ?? new Date().toISOString();
116
+ const f = typeof j["folder"] === "string" ? (j["folder"]).trim() : undefined;
117
+ return {
118
+ id: String(j["id"]),
119
+ name: String(j["name"] ?? "Untitled"),
120
+ schemaVersion: typeof j["schemaVersion"] === "number" ? Math.trunc(j["schemaVersion"]) : 1,
121
+ viewport: viewportFromMap(j["viewport"]),
122
+ created: ts(j["created"]),
123
+ modified: ts(j["modified"]),
124
+ folder: f && f.length > 0 ? f : undefined,
125
+ order: typeof j["order"] === "number" ? Math.trunc(j["order"]) : 0,
126
+ extra: Object.fromEntries(Object.entries(j).filter(([k]) => !MANIFEST_KNOWN.has(k))),
127
+ };
128
+ }
129
+ export function manifestToJson(m) {
130
+ const out = {
131
+ id: m.id,
132
+ name: m.name,
133
+ schemaVersion: m.schemaVersion,
134
+ viewport: { x: m.viewport.x, y: m.viewport.y, zoom: m.viewport.zoom },
135
+ created: m.created,
136
+ modified: m.modified,
137
+ };
138
+ if (m.folder && m.folder.length > 0)
139
+ out["folder"] = m.folder;
140
+ if (m.order !== 0)
141
+ out["order"] = m.order;
142
+ return { ...out, ...m.extra };
143
+ }
144
+ /** A detected feedback loop: variable ids in cycle order + R/B classification. */
145
+ export class DetectedLoop {
146
+ constructor(nodeIds, type) {
147
+ this.nodeIds = nodeIds;
148
+ this.type = type;
149
+ }
150
+ /** Stable identity = type + the sorted unique node ids (one badge per loop). */
151
+ get key() {
152
+ const names = [...this.nodeIds].sort();
153
+ return `${this.type}:${names.join("|")}`;
154
+ }
155
+ }
@@ -0,0 +1,19 @@
1
+ export * from "./view/painter";
2
+ export * from "./view/sceneCache";
3
+ export * from "./view/camera";
4
+ export * from "./view/theme";
5
+ export * from "./view/geometry";
6
+ export * from "./engine/types";
7
+ export * from "./engine/loopGraph";
8
+ export * from "./engine/layout";
9
+ export * from "./engine/storage";
10
+ export * from "./engine/nativeEngine";
11
+ export * from "./engine/noteCodec";
12
+ export * from "./engine/engine";
13
+ export * from "./engine/exporters";
14
+ export * from "./engine/analysis";
15
+ export * from "./engine/subsystemLinks";
16
+ export * from "./engine/loopKey";
17
+ export * from "./engine/loopNote";
18
+ export * from "./engine/noteNaming";
19
+ export * from "./engine/specHash";
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ export * from "./view/painter";
2
+ export * from "./view/sceneCache";
3
+ export * from "./view/camera";
4
+ export * from "./view/theme";
5
+ export * from "./view/geometry";
6
+ export * from "./engine/types";
7
+ export * from "./engine/loopGraph";
8
+ export * from "./engine/layout";
9
+ export * from "./engine/storage";
10
+ export * from "./engine/nativeEngine";
11
+ export * from "./engine/noteCodec";
12
+ export * from "./engine/engine";
13
+ export * from "./engine/exporters";
14
+ export * from "./engine/analysis";
15
+ export * from "./engine/subsystemLinks";
16
+ export * from "./engine/loopKey";
17
+ export * from "./engine/loopNote";
18
+ export * from "./engine/noteNaming";
19
+ export * from "./engine/specHash";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 2D pan/zoom camera. Mirrors the transform model in
3
+ * `app/lib/features/canvas/canvas_screen.dart`:
4
+ * screen = world * scale + translate
5
+ * world = (screen - translate) / scale
6
+ * Zoom is clamped to [0.08, 3.0]; zoom-to-cursor keeps the focal world point
7
+ * pinned under the cursor.
8
+ */
9
+ export interface Point {
10
+ x: number;
11
+ y: number;
12
+ }
13
+ export interface Bounds {
14
+ minX: number;
15
+ minY: number;
16
+ maxX: number;
17
+ maxY: number;
18
+ }
19
+ export declare const MIN_SCALE = 0.08;
20
+ export declare const MAX_SCALE = 3;
21
+ /** Zoom a fresh model opens at when there's nothing to fit (no nodes yet). */
22
+ export declare const DEFAULT_SCALE = 1.2;
23
+ export declare class Camera {
24
+ scale: number;
25
+ tx: number;
26
+ ty: number;
27
+ toScreen(wx: number, wy: number): Point;
28
+ toWorld(sx: number, sy: number): Point;
29
+ panBy(dxScreen: number, dyScreen: number): void;
30
+ /** Zoom by `factor` keeping the world point under (sx,sy) fixed on screen. */
31
+ zoomAt(sx: number, sy: number, factor: number): void;
32
+ setScaleAt(sx: number, sy: number, scale: number): void;
33
+ /** Frame `bounds` (world) within a viewport of `vw`×`vh` screen px. */
34
+ fit(bounds: Bounds, vw: number, vh: number, pad?: number): void;
35
+ /** Pan (no zoom) so world point (wx,wy) sits at the center of a vw×vh view. */
36
+ centerOn(wx: number, wy: number, vw: number, vh: number): void;
37
+ /** Restore the default zoom and center world origin in a vw×vh viewport. Used
38
+ * when opening a model with no saved viewport so an empty model can't inherit
39
+ * the previous view's scale (which could be tiny → first node invisibly small). */
40
+ reset(vw: number, vh: number): void;
41
+ }
42
+ export declare function clamp(v: number, lo: number, hi: number): number;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 2D pan/zoom camera. Mirrors the transform model in
3
+ * `app/lib/features/canvas/canvas_screen.dart`:
4
+ * screen = world * scale + translate
5
+ * world = (screen - translate) / scale
6
+ * Zoom is clamped to [0.08, 3.0]; zoom-to-cursor keeps the focal world point
7
+ * pinned under the cursor.
8
+ */
9
+ export const MIN_SCALE = 0.08;
10
+ export const MAX_SCALE = 3.0;
11
+ /** Zoom a fresh model opens at when there's nothing to fit (no nodes yet). */
12
+ export const DEFAULT_SCALE = 1.2;
13
+ export class Camera {
14
+ constructor() {
15
+ this.scale = DEFAULT_SCALE;
16
+ this.tx = 40;
17
+ this.ty = 120;
18
+ }
19
+ toScreen(wx, wy) {
20
+ return { x: wx * this.scale + this.tx, y: wy * this.scale + this.ty };
21
+ }
22
+ toWorld(sx, sy) {
23
+ return { x: (sx - this.tx) / this.scale, y: (sy - this.ty) / this.scale };
24
+ }
25
+ panBy(dxScreen, dyScreen) {
26
+ this.tx += dxScreen;
27
+ this.ty += dyScreen;
28
+ }
29
+ /** Zoom by `factor` keeping the world point under (sx,sy) fixed on screen. */
30
+ zoomAt(sx, sy, factor) {
31
+ const before = this.toWorld(sx, sy);
32
+ this.scale = clamp(this.scale * factor, MIN_SCALE, MAX_SCALE);
33
+ const after = this.toWorld(sx, sy);
34
+ this.tx += (after.x - before.x) * this.scale;
35
+ this.ty += (after.y - before.y) * this.scale;
36
+ }
37
+ setScaleAt(sx, sy, scale) {
38
+ const before = this.toWorld(sx, sy);
39
+ this.scale = clamp(scale, MIN_SCALE, MAX_SCALE);
40
+ const after = this.toWorld(sx, sy);
41
+ this.tx += (after.x - before.x) * this.scale;
42
+ this.ty += (after.y - before.y) * this.scale;
43
+ }
44
+ /** Frame `bounds` (world) within a viewport of `vw`×`vh` screen px. */
45
+ fit(bounds, vw, vh, pad = 120) {
46
+ const w = Math.max(1, bounds.maxX - bounds.minX);
47
+ const h = Math.max(1, bounds.maxY - bounds.minY);
48
+ // Cap padding to a fraction of the viewport so a fixed margin can't exceed a
49
+ // small (mobile) viewport — a 120px pad on a ~390px phone would leave a
50
+ // negative content box and pin the camera at MIN_SCALE (nodes invisibly tiny).
51
+ const p = Math.min(pad, vw * 0.15, vh * 0.15);
52
+ const sx = (vw - p * 2) / w;
53
+ const sy = (vh - p * 2) / h;
54
+ this.scale = clamp(Math.min(sx, sy), MIN_SCALE, MAX_SCALE);
55
+ const cx = (bounds.minX + bounds.maxX) / 2;
56
+ const cy = (bounds.minY + bounds.maxY) / 2;
57
+ this.tx = vw / 2 - cx * this.scale;
58
+ this.ty = vh / 2 - cy * this.scale;
59
+ }
60
+ /** Pan (no zoom) so world point (wx,wy) sits at the center of a vw×vh view. */
61
+ centerOn(wx, wy, vw, vh) {
62
+ this.tx = vw / 2 - wx * this.scale;
63
+ this.ty = vh / 2 - wy * this.scale;
64
+ }
65
+ /** Restore the default zoom and center world origin in a vw×vh viewport. Used
66
+ * when opening a model with no saved viewport so an empty model can't inherit
67
+ * the previous view's scale (which could be tiny → first node invisibly small). */
68
+ reset(vw, vh) {
69
+ this.scale = DEFAULT_SCALE;
70
+ this.centerOn(0, 0, vw, vh);
71
+ }
72
+ }
73
+ export function clamp(v, lo, hi) {
74
+ return v < lo ? lo : v > hi ? hi : v;
75
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Canvas geometry — node boxes, curved-edge construction, decoration anchors,
3
+ * loop-badge placement, and hit-testing. A faithful port of the geometry in
4
+ * `app/lib/painters/graph_painter.dart`: edges are TRUE CIRCULAR arcs through
5
+ * start → apex → end (constant curvature, so dragging an edge out bulges it into
6
+ * a round arc, not a pinched parabola), trimmed to the node rims. Pure module —
7
+ * no `obsidian`, no canvas — so the math is unit-testable.
8
+ */
9
+ import { DetectedLoop, VariableFile, VaultLink } from "../engine/types";
10
+ import { Bounds, Point } from "./camera";
11
+ export interface NodeBox {
12
+ id: string;
13
+ cx: number;
14
+ cy: number;
15
+ w: number;
16
+ h: number;
17
+ type: string;
18
+ }
19
+ export interface EdgeRef {
20
+ id: string;
21
+ source: string;
22
+ target: string;
23
+ link: VaultLink;
24
+ }
25
+ export interface EdgeGeom extends EdgeRef {
26
+ points: Point[];
27
+ mid: Point;
28
+ midAngle: number;
29
+ delay: Point;
30
+ delayAngle: number;
31
+ nl: Point;
32
+ nlAngle: number;
33
+ arrowTip: Point;
34
+ arrowAngle: number;
35
+ }
36
+ /**
37
+ * Node (x,y) is the box center, matching the Dart painter + autoLayout.
38
+ *
39
+ * The app's painter (`NodeBox.sizeFor`) sizes each box to the *measured* label
40
+ * width plus padding — `max(60, textWidth + (flow ? 40 : 36))`, height 40 for a
41
+ * stock else 34. Pass `measure` (a canvas `measureText` at the label font) to
42
+ * match it exactly; without one (tests/headless) fall back to the same
43
+ * character-count estimate `layout.boxSize`/`autoLayout` use, so positions stay
44
+ * deterministic.
45
+ */
46
+ export declare function buildNodeBoxes(nodes: VariableFile[], measure?: (label: string) => number): Map<string, NodeBox>;
47
+ export declare function collectEdges(nodes: VariableFile[]): EdgeRef[];
48
+ /**
49
+ * @param bowCache Per-edge frozen bow signs (keyed by edge id). Lone edges pick
50
+ * a bow side from the graph centroid **once** and reuse it; without this, a
51
+ * node drag moves the centroid and can flip an unrelated edge to the far side.
52
+ * Pass a stable Map (cleared per model) to get app-faithful, jitter-free arcs.
53
+ */
54
+ export declare function buildEdgeGeoms(edges: EdgeRef[], boxes: Map<string, NodeBox>, bowCache?: Map<string, number>): EdgeGeom[];
55
+ /**
56
+ * Loop-badge centers — centroid of member nodes, then light overlap spread.
57
+ * `overrides` (loop.key → world point) pins badges the user has dragged: a pinned
58
+ * badge sits exactly where it was dropped and does not move during relaxation;
59
+ * unpinned badges relax around it. Mirrors the app's `loopBadgeOverrides`
60
+ * (session-only, keyed by loop key — not persisted to the vault).
61
+ */
62
+ export declare function computeBadges(loops: DetectedLoop[], boxes: Map<string, NodeBox>, overrides?: Map<string, Point>): Map<string, Point>;
63
+ /**
64
+ * Directed edge ids (`source__target`, matching `collectEdges`) that close a
65
+ * detected loop's cycle. `nodeIds` is the open path, so the last node wraps back
66
+ * to the first. Used to highlight the loop when its badge is selected.
67
+ */
68
+ export declare function loopEdgeIds(loop: DetectedLoop): Set<string>;
69
+ export declare function hitNode(boxes: Map<string, NodeBox>, p: Point): string | null;
70
+ export declare function hitEdge(geoms: EdgeGeom[], p: Point, scale: number): string | null;
71
+ /**
72
+ * Generous grab zone for an already-selected edge, mirroring the native app's
73
+ * `_nearSelectedEdge`: within 26/scale of the midpoint handle, OR within
74
+ * 22/scale of the curve. Bowing uses this (not the tight 14/scale `hitEdge`
75
+ * select zone) so "select then drag to bow" is reliable instead of needing a
76
+ * pixel-perfect second press on the line.
77
+ */
78
+ export declare function nearSelectedEdge(g: EdgeGeom, p: Point, scale: number): boolean;
79
+ export declare function hitBadge(badges: Map<string, Point>, p: Point, scale: number): string | null;
80
+ /** True if `p` is in the connect-ring band just outside a node's rim. */
81
+ export declare function inConnectBand(box: NodeBox, p: Point, tol: number, gap?: number): boolean;
82
+ export declare function distToSegment(p: Point, a: Point, b: Point): number;
83
+ export declare function nodeBounds(boxes: Map<string, NodeBox>): Bounds;