@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.
- package/dist/engine/analysis.d.ts +15 -0
- package/dist/engine/analysis.js +30 -0
- package/dist/engine/engine.d.ts +203 -0
- package/dist/engine/engine.js +7 -0
- package/dist/engine/exporters.d.ts +45 -0
- package/dist/engine/exporters.js +110 -0
- package/dist/engine/layout.d.ts +19 -0
- package/dist/engine/layout.js +142 -0
- package/dist/engine/loopGraph.d.ts +51 -0
- package/dist/engine/loopGraph.js +0 -0
- package/dist/engine/loopKey.d.ts +24 -0
- package/dist/engine/loopKey.js +32 -0
- package/dist/engine/loopNote.d.ts +40 -0
- package/dist/engine/loopNote.js +130 -0
- package/dist/engine/nativeEngine.d.ts +175 -0
- package/dist/engine/nativeEngine.js +1120 -0
- package/dist/engine/noteCodec.d.ts +29 -0
- package/dist/engine/noteCodec.js +254 -0
- package/dist/engine/noteNaming.d.ts +18 -0
- package/dist/engine/noteNaming.js +28 -0
- package/dist/engine/specHash.d.ts +37 -0
- package/dist/engine/specHash.js +86 -0
- package/dist/engine/storage.d.ts +49 -0
- package/dist/engine/storage.js +116 -0
- package/dist/engine/subsystemLinks.d.ts +29 -0
- package/dist/engine/subsystemLinks.js +55 -0
- package/dist/engine/types.d.ts +103 -0
- package/dist/engine/types.js +155 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/view/camera.d.ts +42 -0
- package/dist/view/camera.js +75 -0
- package/dist/view/geometry.d.ts +83 -0
- package/dist/view/geometry.js +408 -0
- package/dist/view/painter.d.ts +63 -0
- package/dist/view/painter.js +471 -0
- package/dist/view/sceneCache.d.ts +61 -0
- package/dist/view/sceneCache.js +134 -0
- package/dist/view/theme.d.ts +57 -0
- package/dist/view/theme.js +90 -0
- package/package.json +17 -0
|
@@ -0,0 +1,408 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Node (x,y) is the box center, matching the Dart painter + autoLayout.
|
|
11
|
+
*
|
|
12
|
+
* The app's painter (`NodeBox.sizeFor`) sizes each box to the *measured* label
|
|
13
|
+
* width plus padding — `max(60, textWidth + (flow ? 40 : 36))`, height 40 for a
|
|
14
|
+
* stock else 34. Pass `measure` (a canvas `measureText` at the label font) to
|
|
15
|
+
* match it exactly; without one (tests/headless) fall back to the same
|
|
16
|
+
* character-count estimate `layout.boxSize`/`autoLayout` use, so positions stay
|
|
17
|
+
* deterministic.
|
|
18
|
+
*/
|
|
19
|
+
export function buildNodeBoxes(nodes, measure) {
|
|
20
|
+
const m = new Map();
|
|
21
|
+
for (const n of nodes) {
|
|
22
|
+
const label = n.label || n.id;
|
|
23
|
+
const h = n.type === "stock" ? 40 : 34;
|
|
24
|
+
const extra = n.type === "flow" ? 40 : 36;
|
|
25
|
+
const textW = measure ? measure(label) : label.length * 7.2;
|
|
26
|
+
const w = Math.max(60, textW + extra);
|
|
27
|
+
m.set(n.id, { id: n.id, cx: n.x, cy: n.y, w, h, type: n.type });
|
|
28
|
+
}
|
|
29
|
+
return m;
|
|
30
|
+
}
|
|
31
|
+
export function collectEdges(nodes) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const n of nodes) {
|
|
34
|
+
for (const l of n.links) {
|
|
35
|
+
out.push({ id: `${n.id}__${l.to}`, source: n.id, target: l.to, link: l });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* @param bowCache Per-edge frozen bow signs (keyed by edge id). Lone edges pick
|
|
42
|
+
* a bow side from the graph centroid **once** and reuse it; without this, a
|
|
43
|
+
* node drag moves the centroid and can flip an unrelated edge to the far side.
|
|
44
|
+
* Pass a stable Map (cleared per model) to get app-faithful, jitter-free arcs.
|
|
45
|
+
*/
|
|
46
|
+
export function buildEdgeGeoms(edges, boxes, bowCache) {
|
|
47
|
+
const present = edges.filter((e) => boxes.has(e.source) && boxes.has(e.target));
|
|
48
|
+
const dirSet = new Set(present.map((e) => `${e.source}>${e.target}`));
|
|
49
|
+
const centroid = centroidOf(boxes);
|
|
50
|
+
return present.map((e) => geomFor(e, boxes, dirSet, centroid, bowCache));
|
|
51
|
+
}
|
|
52
|
+
function centroidOf(boxes) {
|
|
53
|
+
let sx = 0;
|
|
54
|
+
let sy = 0;
|
|
55
|
+
let n = 0;
|
|
56
|
+
for (const b of boxes.values()) {
|
|
57
|
+
sx += b.cx;
|
|
58
|
+
sy += b.cy;
|
|
59
|
+
n++;
|
|
60
|
+
}
|
|
61
|
+
return n === 0 ? { x: 0, y: 0 } : { x: sx / n, y: sy / n };
|
|
62
|
+
}
|
|
63
|
+
function geomFor(e, boxes, dirSet, centroid, bowCache) {
|
|
64
|
+
const a = boxes.get(e.source);
|
|
65
|
+
const b = boxes.get(e.target);
|
|
66
|
+
if (e.source === e.target)
|
|
67
|
+
return selfLoop(e, a);
|
|
68
|
+
const S = { x: a.cx, y: a.cy };
|
|
69
|
+
const E = { x: b.cx, y: b.cy };
|
|
70
|
+
const dx = E.x - S.x;
|
|
71
|
+
const dy = E.y - S.y;
|
|
72
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
73
|
+
const mx = (S.x + E.x) / 2;
|
|
74
|
+
const my = (S.y + E.y) / 2;
|
|
75
|
+
const nx = -dy / len; // perpendicular unit
|
|
76
|
+
const ny = dx / len;
|
|
77
|
+
const mag = len * 0.18 + 18;
|
|
78
|
+
let bow;
|
|
79
|
+
if (e.link.curvature !== undefined) {
|
|
80
|
+
// User-set curve: curvature is the signed apex offset from the chord
|
|
81
|
+
// midpoint along the perpendicular (set by dragging the edge).
|
|
82
|
+
bow = e.link.curvature;
|
|
83
|
+
}
|
|
84
|
+
else if (dirSet.has(`${e.target}>${e.source}`)) {
|
|
85
|
+
// Reciprocal pair — both use the same bow sign; their opposite chord
|
|
86
|
+
// directions make the two arcs split to opposite geometric sides.
|
|
87
|
+
bow = mag;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Lone edge — bow away from the graph centroid so loops read as circles,
|
|
91
|
+
// but FREEZE the chosen side per edge: a later node drag shifts the centroid
|
|
92
|
+
// and would otherwise flip an unrelated edge to the other side mid-drag.
|
|
93
|
+
let sign = bowCache?.get(e.id);
|
|
94
|
+
if (sign === undefined) {
|
|
95
|
+
const ox = mx - centroid.x;
|
|
96
|
+
const oy = my - centroid.y;
|
|
97
|
+
sign = nx * ox + ny * oy >= 0 ? 1 : -1;
|
|
98
|
+
bowCache?.set(e.id, sign);
|
|
99
|
+
}
|
|
100
|
+
bow = mag * sign;
|
|
101
|
+
}
|
|
102
|
+
// Apex = the chord midpoint pushed out by the signed bow. The visible edge is
|
|
103
|
+
// a TRUE CIRCULAR arc through start → apex → end (round, constant-curvature),
|
|
104
|
+
// matching the app's `_circleCenter` arc — so dragging an edge out bulges it
|
|
105
|
+
// into a circle, not a pinched parabola. Endpoints trim to each node's rim
|
|
106
|
+
// (aimed at the apex); falls back to a straight chord when nearly collinear.
|
|
107
|
+
const apex = { x: mx + nx * bow, y: my + ny * bow };
|
|
108
|
+
const start = rimPoint(a, apex);
|
|
109
|
+
const end = rimPoint(b, apex);
|
|
110
|
+
const center = circleCenter(start, apex, end);
|
|
111
|
+
let points;
|
|
112
|
+
if (!center) {
|
|
113
|
+
points = [start, end];
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const r = Math.hypot(start.x - center.x, start.y - center.y);
|
|
117
|
+
const a0 = Math.atan2(start.y - center.y, start.x - center.x);
|
|
118
|
+
const a1 = Math.atan2(end.y - center.y, end.x - center.x);
|
|
119
|
+
const am = Math.atan2(apex.y - center.y, apex.x - center.x);
|
|
120
|
+
const toEnd = norm2pi(a1 - a0);
|
|
121
|
+
const toMid = norm2pi(am - a0);
|
|
122
|
+
// sweep in the direction that passes through the apex
|
|
123
|
+
const sweep = toMid < toEnd ? toEnd : toEnd - 2 * Math.PI;
|
|
124
|
+
const N = 24;
|
|
125
|
+
points = [];
|
|
126
|
+
for (let i = 0; i <= N; i++) {
|
|
127
|
+
const ang = a0 + sweep * (i / N);
|
|
128
|
+
points.push({ x: center.x + r * Math.cos(ang), y: center.y + r * Math.sin(ang) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return decorate(e, points);
|
|
132
|
+
}
|
|
133
|
+
/** Circumcircle center of three points; null when (nearly) collinear. */
|
|
134
|
+
function circleCenter(a, b, c) {
|
|
135
|
+
const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
|
|
136
|
+
if (Math.abs(d) < 1e-6)
|
|
137
|
+
return null;
|
|
138
|
+
const a2 = a.x * a.x + a.y * a.y;
|
|
139
|
+
const b2 = b.x * b.x + b.y * b.y;
|
|
140
|
+
const c2 = c.x * c.x + c.y * c.y;
|
|
141
|
+
return {
|
|
142
|
+
x: (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d,
|
|
143
|
+
y: (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** March from a box center toward `toward` until just outside the rim. */
|
|
147
|
+
function rimPoint(box, toward) {
|
|
148
|
+
const c0 = { x: box.cx, y: box.cy };
|
|
149
|
+
const dx = toward.x - c0.x;
|
|
150
|
+
const dy = toward.y - c0.y;
|
|
151
|
+
const dist = Math.hypot(dx, dy);
|
|
152
|
+
if (dist === 0)
|
|
153
|
+
return c0;
|
|
154
|
+
const sx = dx / dist;
|
|
155
|
+
const sy = dy / dist;
|
|
156
|
+
for (let d = 0; d < dist; d += 1.5) {
|
|
157
|
+
const p = { x: c0.x + sx * d, y: c0.y + sy * d };
|
|
158
|
+
if (!inBox(p, box, 0))
|
|
159
|
+
return p;
|
|
160
|
+
}
|
|
161
|
+
return c0;
|
|
162
|
+
}
|
|
163
|
+
/** Wrap an angle into [0, 2π). */
|
|
164
|
+
function norm2pi(x) {
|
|
165
|
+
let v = x;
|
|
166
|
+
while (v < 0)
|
|
167
|
+
v += 2 * Math.PI;
|
|
168
|
+
while (v >= 2 * Math.PI)
|
|
169
|
+
v -= 2 * Math.PI;
|
|
170
|
+
return v;
|
|
171
|
+
}
|
|
172
|
+
function selfLoop(e, a) {
|
|
173
|
+
const r = Math.max(a.w, a.h) / 2 + 16;
|
|
174
|
+
const cx = a.cx;
|
|
175
|
+
const cy = a.cy - a.h / 2 - r;
|
|
176
|
+
const N = 40;
|
|
177
|
+
const points = [];
|
|
178
|
+
for (let i = 0; i <= N; i++) {
|
|
179
|
+
const ang = -Math.PI * 0.85 + Math.PI * 1.7 * (i / N);
|
|
180
|
+
points.push({ x: cx + r * Math.cos(ang), y: cy + r * Math.sin(ang) });
|
|
181
|
+
}
|
|
182
|
+
return decorate(e, points);
|
|
183
|
+
}
|
|
184
|
+
function decorate(e, points) {
|
|
185
|
+
const pts = points.length >= 2 ? points : [points[0] ?? { x: 0, y: 0 }, points[0] ?? { x: 0, y: 0 }];
|
|
186
|
+
const last = pts[pts.length - 1];
|
|
187
|
+
const prev = pts[pts.length - 2];
|
|
188
|
+
const mid = atFraction(pts, 0.5);
|
|
189
|
+
const delay = atFraction(pts, 0.34);
|
|
190
|
+
const nl = atFraction(pts, 0.66);
|
|
191
|
+
return {
|
|
192
|
+
...e,
|
|
193
|
+
points: pts,
|
|
194
|
+
mid: mid.point,
|
|
195
|
+
midAngle: mid.angle,
|
|
196
|
+
delay: delay.point,
|
|
197
|
+
delayAngle: delay.angle,
|
|
198
|
+
nl: nl.point,
|
|
199
|
+
nlAngle: nl.angle,
|
|
200
|
+
arrowTip: last,
|
|
201
|
+
arrowAngle: Math.atan2(last.y - prev.y, last.x - prev.x),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Point + tangent angle at fraction `f` of a polyline's arc length. */
|
|
205
|
+
function atFraction(pts, f) {
|
|
206
|
+
if (pts.length < 2)
|
|
207
|
+
return { point: pts[0] ?? { x: 0, y: 0 }, angle: 0 };
|
|
208
|
+
let total = 0;
|
|
209
|
+
const seg = [];
|
|
210
|
+
for (let i = 1; i < pts.length; i++) {
|
|
211
|
+
const d = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
|
212
|
+
seg.push(d);
|
|
213
|
+
total += d;
|
|
214
|
+
}
|
|
215
|
+
let target = f * total;
|
|
216
|
+
for (let i = 0; i < seg.length; i++) {
|
|
217
|
+
if (target <= seg[i] || i === seg.length - 1) {
|
|
218
|
+
const a = pts[i];
|
|
219
|
+
const b = pts[i + 1];
|
|
220
|
+
const r = seg[i] === 0 ? 0 : target / seg[i];
|
|
221
|
+
return {
|
|
222
|
+
point: { x: a.x + (b.x - a.x) * r, y: a.y + (b.y - a.y) * r },
|
|
223
|
+
angle: Math.atan2(b.y - a.y, b.x - a.x),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
target -= seg[i];
|
|
227
|
+
}
|
|
228
|
+
const a = pts[pts.length - 2];
|
|
229
|
+
const b = pts[pts.length - 1];
|
|
230
|
+
return { point: b, angle: Math.atan2(b.y - a.y, b.x - a.x) };
|
|
231
|
+
}
|
|
232
|
+
function inBox(p, box, pad) {
|
|
233
|
+
return (Math.abs(p.x - box.cx) <= box.w / 2 + pad &&
|
|
234
|
+
Math.abs(p.y - box.cy) <= box.h / 2 + pad);
|
|
235
|
+
}
|
|
236
|
+
// ---- loop badges -----------------------------------------------------------
|
|
237
|
+
/**
|
|
238
|
+
* Loop-badge centers — centroid of member nodes, then light overlap spread.
|
|
239
|
+
* `overrides` (loop.key → world point) pins badges the user has dragged: a pinned
|
|
240
|
+
* badge sits exactly where it was dropped and does not move during relaxation;
|
|
241
|
+
* unpinned badges relax around it. Mirrors the app's `loopBadgeOverrides`
|
|
242
|
+
* (session-only, keyed by loop key — not persisted to the vault).
|
|
243
|
+
*/
|
|
244
|
+
export function computeBadges(loops, boxes, overrides) {
|
|
245
|
+
const pos = new Map();
|
|
246
|
+
const pinned = new Set();
|
|
247
|
+
for (const lp of loops) {
|
|
248
|
+
const ov = overrides?.get(lp.key);
|
|
249
|
+
if (ov) {
|
|
250
|
+
pos.set(lp.key, { x: ov.x, y: ov.y });
|
|
251
|
+
pinned.add(lp.key);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
let sx = 0;
|
|
255
|
+
let sy = 0;
|
|
256
|
+
let c = 0;
|
|
257
|
+
for (const id of new Set(lp.nodeIds)) {
|
|
258
|
+
const b = boxes.get(id);
|
|
259
|
+
if (b) {
|
|
260
|
+
sx += b.cx;
|
|
261
|
+
sy += b.cy;
|
|
262
|
+
c++;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (c > 0)
|
|
266
|
+
pos.set(lp.key, { x: sx / c, y: sy / c });
|
|
267
|
+
}
|
|
268
|
+
const keys = [...pos.keys()];
|
|
269
|
+
const MIN = 44;
|
|
270
|
+
for (let iter = 0; iter < 50; iter++) {
|
|
271
|
+
let moved = false;
|
|
272
|
+
for (let i = 0; i < keys.length; i++) {
|
|
273
|
+
for (let j = i + 1; j < keys.length; j++) {
|
|
274
|
+
const pPin = pinned.has(keys[i]);
|
|
275
|
+
const qPin = pinned.has(keys[j]);
|
|
276
|
+
if (pPin && qPin)
|
|
277
|
+
continue; // both dropped by hand — leave them
|
|
278
|
+
const p = pos.get(keys[i]);
|
|
279
|
+
const q = pos.get(keys[j]);
|
|
280
|
+
const dx = q.x - p.x;
|
|
281
|
+
const dy = q.y - p.y;
|
|
282
|
+
const d = Math.hypot(dx, dy);
|
|
283
|
+
if (d < MIN && d > 0.001) {
|
|
284
|
+
const ux = dx / d;
|
|
285
|
+
const uy = dy / d;
|
|
286
|
+
// A pinned badge holds; its partner takes the whole push.
|
|
287
|
+
const pushP = pPin ? 0 : qPin ? MIN - d : (MIN - d) / 2;
|
|
288
|
+
const pushQ = qPin ? 0 : pPin ? MIN - d : (MIN - d) / 2;
|
|
289
|
+
p.x -= ux * pushP;
|
|
290
|
+
p.y -= uy * pushP;
|
|
291
|
+
q.x += ux * pushQ;
|
|
292
|
+
q.y += uy * pushQ;
|
|
293
|
+
moved = true;
|
|
294
|
+
}
|
|
295
|
+
else if (d <= 0.001) {
|
|
296
|
+
if (!qPin) {
|
|
297
|
+
q.x += MIN / 2;
|
|
298
|
+
moved = true;
|
|
299
|
+
}
|
|
300
|
+
else if (!pPin) {
|
|
301
|
+
p.x -= MIN / 2;
|
|
302
|
+
moved = true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!moved)
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
return pos;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Directed edge ids (`source__target`, matching `collectEdges`) that close a
|
|
314
|
+
* detected loop's cycle. `nodeIds` is the open path, so the last node wraps back
|
|
315
|
+
* to the first. Used to highlight the loop when its badge is selected.
|
|
316
|
+
*/
|
|
317
|
+
export function loopEdgeIds(loop) {
|
|
318
|
+
const ids = loop.nodeIds;
|
|
319
|
+
const out = new Set();
|
|
320
|
+
for (let i = 0; i < ids.length; i++) {
|
|
321
|
+
out.add(`${ids[i]}__${ids[(i + 1) % ids.length]}`);
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
// ---- hit testing -----------------------------------------------------------
|
|
326
|
+
export function hitNode(boxes, p) {
|
|
327
|
+
const arr = [...boxes.values()];
|
|
328
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
329
|
+
if (inBox(p, arr[i], 4))
|
|
330
|
+
return arr[i].id;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
export function hitEdge(geoms, p, scale) {
|
|
335
|
+
const tol = 14 / scale;
|
|
336
|
+
let best = null;
|
|
337
|
+
let bestD = tol;
|
|
338
|
+
for (const g of geoms) {
|
|
339
|
+
for (let i = 0; i + 1 < g.points.length; i++) {
|
|
340
|
+
const d = distToSegment(p, g.points[i], g.points[i + 1]);
|
|
341
|
+
if (d < bestD) {
|
|
342
|
+
bestD = d;
|
|
343
|
+
best = g.id;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return best;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Generous grab zone for an already-selected edge, mirroring the native app's
|
|
351
|
+
* `_nearSelectedEdge`: within 26/scale of the midpoint handle, OR within
|
|
352
|
+
* 22/scale of the curve. Bowing uses this (not the tight 14/scale `hitEdge`
|
|
353
|
+
* select zone) so "select then drag to bow" is reliable instead of needing a
|
|
354
|
+
* pixel-perfect second press on the line.
|
|
355
|
+
*/
|
|
356
|
+
export function nearSelectedEdge(g, p, scale) {
|
|
357
|
+
if (Math.hypot(g.mid.x - p.x, g.mid.y - p.y) < 26 / scale)
|
|
358
|
+
return true;
|
|
359
|
+
let best = Infinity;
|
|
360
|
+
for (let i = 0; i + 1 < g.points.length; i++) {
|
|
361
|
+
const d = distToSegment(p, g.points[i], g.points[i + 1]);
|
|
362
|
+
if (d < best)
|
|
363
|
+
best = d;
|
|
364
|
+
}
|
|
365
|
+
return best < 22 / scale;
|
|
366
|
+
}
|
|
367
|
+
export function hitBadge(badges, p, scale) {
|
|
368
|
+
const tol = 22 / scale;
|
|
369
|
+
let best = null;
|
|
370
|
+
let bestD = tol;
|
|
371
|
+
for (const [key, c] of badges) {
|
|
372
|
+
const d = Math.hypot(c.x - p.x, c.y - p.y);
|
|
373
|
+
if (d < bestD) {
|
|
374
|
+
bestD = d;
|
|
375
|
+
best = key;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return best;
|
|
379
|
+
}
|
|
380
|
+
/** True if `p` is in the connect-ring band just outside a node's rim. */
|
|
381
|
+
export function inConnectBand(box, p, tol, gap = 20) {
|
|
382
|
+
return inBox(p, box, gap + tol) && !inBox(p, box, 0);
|
|
383
|
+
}
|
|
384
|
+
export function distToSegment(p, a, b) {
|
|
385
|
+
const dx = b.x - a.x;
|
|
386
|
+
const dy = b.y - a.y;
|
|
387
|
+
const l2 = dx * dx + dy * dy;
|
|
388
|
+
if (l2 === 0)
|
|
389
|
+
return Math.hypot(p.x - a.x, p.y - a.y);
|
|
390
|
+
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2;
|
|
391
|
+
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
392
|
+
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
|
393
|
+
}
|
|
394
|
+
export function nodeBounds(boxes) {
|
|
395
|
+
if (boxes.size === 0)
|
|
396
|
+
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
397
|
+
let minX = Infinity;
|
|
398
|
+
let minY = Infinity;
|
|
399
|
+
let maxX = -Infinity;
|
|
400
|
+
let maxY = -Infinity;
|
|
401
|
+
for (const b of boxes.values()) {
|
|
402
|
+
minX = Math.min(minX, b.cx - b.w / 2);
|
|
403
|
+
minY = Math.min(minY, b.cy - b.h / 2);
|
|
404
|
+
maxX = Math.max(maxX, b.cx + b.w / 2);
|
|
405
|
+
maxY = Math.max(maxY, b.cy + b.h / 2);
|
|
406
|
+
}
|
|
407
|
+
return { minX, minY, maxX, maxY };
|
|
408
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas painter — renders a prepared scene (node boxes, curved edges, loop
|
|
3
|
+
* badges) with the app's visual language ported from
|
|
4
|
+
* `app/lib/painters/graph_painter.dart`. Pure: takes a 2D context + scene +
|
|
5
|
+
* camera + theme + interaction state and draws. Deferred flourishes (valence/CLA
|
|
6
|
+
* marks) are tracked in Status.md; the core CLD reading — node types, arc edges
|
|
7
|
+
* with polarity chips, R/B badges, the subsystem-link corner mark, and selection
|
|
8
|
+
* + live-edit highlight — is here. Per-node
|
|
9
|
+
* quantitative detail lives in the ƒx node-menu modal, not on the canvas.
|
|
10
|
+
*/
|
|
11
|
+
import { Camera, Point } from "./camera";
|
|
12
|
+
import { Theme } from "./theme";
|
|
13
|
+
import { EdgeGeom, NodeBox } from "./geometry";
|
|
14
|
+
import { DetectedLoop, LoopType, VariableFile } from "../engine/types";
|
|
15
|
+
export interface Scene {
|
|
16
|
+
nodes: VariableFile[];
|
|
17
|
+
boxes: Map<string, NodeBox>;
|
|
18
|
+
edges: EdgeGeom[];
|
|
19
|
+
loops: DetectedLoop[];
|
|
20
|
+
labels: Map<string, string>;
|
|
21
|
+
badges: Map<string, Point>;
|
|
22
|
+
}
|
|
23
|
+
/** A selected loop's members, so the painter can spotlight just that cycle. */
|
|
24
|
+
export interface LoopHighlight {
|
|
25
|
+
edgeIds: Set<string>;
|
|
26
|
+
nodeIds: Set<string>;
|
|
27
|
+
type: LoopType;
|
|
28
|
+
}
|
|
29
|
+
export interface PaintUi {
|
|
30
|
+
cssWidth: number;
|
|
31
|
+
cssHeight: number;
|
|
32
|
+
dpr: number;
|
|
33
|
+
selectedNodeId: string | null;
|
|
34
|
+
selectedEdgeId: string | null;
|
|
35
|
+
selectedLoopKey: string | null;
|
|
36
|
+
liveNodeIds: Set<string>;
|
|
37
|
+
/** Live link being drawn: from node id to a world point. */
|
|
38
|
+
linkPreview: {
|
|
39
|
+
from: string;
|
|
40
|
+
to: Point;
|
|
41
|
+
} | null;
|
|
42
|
+
/** Node whose connect-ring is currently armed/hovered. */
|
|
43
|
+
connectNodeId: string | null;
|
|
44
|
+
/** Edges/nodes of the selected loop; non-members dim. Null = no spotlight. */
|
|
45
|
+
loopHighlight: LoopHighlight | null;
|
|
46
|
+
/** 0..1 pulse clock for the selected node's halo (0 = static). */
|
|
47
|
+
pulsePhase: number;
|
|
48
|
+
/** 0..1 marching-ants clock tracing highlighted loop edges. */
|
|
49
|
+
flowPhase: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Canvas label font. The desktop app renders its canvas text in Helvetica Neue
|
|
53
|
+
* (`ThemeData.fontFamily` in `app/lib/main.dart`) at weight 600. A
|
|
54
|
+
* `var(--font-interface)` inside a canvas `font` string is NOT resolved by the
|
|
55
|
+
* 2D context — the weight silently falls back to 400 and the family to the
|
|
56
|
+
* system UI font — so the family and weight are spelled out concretely here to
|
|
57
|
+
* match the app pixel-for-pixel. The monospace badge font mirrors Dart's
|
|
58
|
+
* generic `'monospace'`.
|
|
59
|
+
*/
|
|
60
|
+
export declare const LABEL_FONT = "\"Helvetica Neue\", Helvetica, Arial, sans-serif";
|
|
61
|
+
/** Matches the app's `letterSpacing: -0.1` on every canvas label. */
|
|
62
|
+
export declare const LABEL_TRACKING = "-0.1px";
|
|
63
|
+
export declare function paint(ctx: CanvasRenderingContext2D, scene: Scene, camera: Camera, theme: Theme, ui: PaintUi): void;
|