@neoloopy/cld-canvas 0.1.4 → 0.1.8
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/LICENSE +1 -1
- package/README.md +22 -3
- package/dist/engine/engine.d.ts +16 -4
- package/dist/engine/exporters.d.ts +1 -1
- package/dist/engine/exporters.js +1 -1
- package/dist/engine/loopGraph.d.ts +8 -4
- package/dist/engine/loopGraph.js +0 -0
- package/dist/engine/loopKey.d.ts +9 -0
- package/dist/engine/loopKey.js +14 -0
- package/dist/engine/nativeEngine.d.ts +8 -2
- package/dist/engine/nativeEngine.js +193 -56
- package/dist/engine/noteCodec.js +46 -2
- package/dist/engine/quantCanvasLoops.d.ts +36 -0
- package/dist/engine/quantCanvasLoops.js +694 -0
- package/dist/engine/sfd.d.ts +47 -0
- package/dist/engine/sfd.js +320 -0
- package/dist/engine/specHash.js +4 -0
- package/dist/engine/types.d.ts +54 -3
- package/dist/engine/types.js +66 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/view/geometry.d.ts +35 -1
- package/dist/view/geometry.js +174 -7
- package/dist/view/painter.d.ts +10 -1
- package/dist/view/painter.js +243 -15
- package/dist/view/sceneCache.d.ts +2 -1
- package/dist/view/sceneCache.js +35 -13
- package/package.json +2 -2
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { VariableFile, VaultLink } from "./types";
|
|
2
|
+
export declare const SOURCE_CLOUD = "~source";
|
|
3
|
+
export declare const SINK_CLOUD = "~sink";
|
|
4
|
+
export interface FlowSpec {
|
|
5
|
+
from: string;
|
|
6
|
+
to: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SfdPosition {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
}
|
|
12
|
+
export interface FlowTouch {
|
|
13
|
+
stockId: string;
|
|
14
|
+
sign: 1 | -1;
|
|
15
|
+
}
|
|
16
|
+
export declare function isCloud(end: string | null | undefined): boolean;
|
|
17
|
+
/** Explicit `extra.flow` topology on a flow note, or null when absent/malformed. */
|
|
18
|
+
export declare function flowOf(v: VariableFile): FlowSpec | null;
|
|
19
|
+
/** Optional SFD-view position from `extra.sfd`, distinct from CLD `x`/`y`. */
|
|
20
|
+
export declare function sfdPositionOf(v: VariableFile): SfdPosition | null;
|
|
21
|
+
/** True when the model has any authored SFD coordinate. */
|
|
22
|
+
export declare function hasAuthoredSfd(nodes: VariableFile[]): boolean;
|
|
23
|
+
export declare function extraWithFlow(extra: Record<string, unknown>, flow: FlowSpec): Record<string, unknown>;
|
|
24
|
+
export declare function extraWithoutFlow(extra: Record<string, unknown>): Record<string, unknown>;
|
|
25
|
+
export declare function extraWithSfdPosition(extra: Record<string, unknown>, x: number, y: number): Record<string, unknown>;
|
|
26
|
+
/** The stock deltas this flow implies: +1 inflow, -1 outflow. */
|
|
27
|
+
export declare function flowTouches(flow: VariableFile, byId: Map<string, VariableFile>): FlowTouch[];
|
|
28
|
+
/**
|
|
29
|
+
* Material endpoints, using an authoritative stored block when present and
|
|
30
|
+
* legacy flow->stock inference only when that block is absent.
|
|
31
|
+
* Returns null when the flow cannot be represented as exactly one from/to pair.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveFlowSpec(flow: VariableFile, byId: Map<string, VariableFile>): FlowSpec | null;
|
|
34
|
+
export declare function validateFlowEndpoints(from: string, to: string, byId: Map<string, VariableFile>): {
|
|
35
|
+
ok: true;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
error: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* In SFD mode, material flow->stock links are represented by pipes. Remaining
|
|
42
|
+
* links, including auxiliaries feeding a flow's rate, stay information connectors.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isMaterialLink(source: VariableFile, link: VaultLink, byId: Map<string, VariableFile>): boolean;
|
|
45
|
+
/** Deterministic SFD fallback layout, ported from loopy core `sfd_layout.dart`. */
|
|
46
|
+
export declare function computeSfdLayout(vars: VariableFile[], byId: Map<string, VariableFile>): Map<string, SfdPosition>;
|
|
47
|
+
export declare function sfdPositionsFor(nodes: VariableFile[]): Map<string, SfdPosition>;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
export const SOURCE_CLOUD = "~source";
|
|
2
|
+
export const SINK_CLOUD = "~sink";
|
|
3
|
+
export function isCloud(end) {
|
|
4
|
+
return end === SOURCE_CLOUD || end === SINK_CLOUD;
|
|
5
|
+
}
|
|
6
|
+
function objectMap(v) {
|
|
7
|
+
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
8
|
+
? v
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
function cleanEndpoint(v) {
|
|
12
|
+
return String(v ?? "").trim();
|
|
13
|
+
}
|
|
14
|
+
function num(v) {
|
|
15
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
16
|
+
}
|
|
17
|
+
/** Explicit `extra.flow` topology on a flow note, or null when absent/malformed. */
|
|
18
|
+
export function flowOf(v) {
|
|
19
|
+
const f = objectMap(v.extra["flow"]);
|
|
20
|
+
if (!f)
|
|
21
|
+
return null;
|
|
22
|
+
const from = cleanEndpoint(f["from"]);
|
|
23
|
+
const to = cleanEndpoint(f["to"]);
|
|
24
|
+
return from.length > 0 && to.length > 0 ? { from, to } : null;
|
|
25
|
+
}
|
|
26
|
+
/** Optional SFD-view position from `extra.sfd`, distinct from CLD `x`/`y`. */
|
|
27
|
+
export function sfdPositionOf(v) {
|
|
28
|
+
const s = objectMap(v.extra["sfd"]);
|
|
29
|
+
if (!s)
|
|
30
|
+
return null;
|
|
31
|
+
const x = num(s["x"]);
|
|
32
|
+
const y = num(s["y"]);
|
|
33
|
+
return x !== undefined && y !== undefined ? { x, y } : null;
|
|
34
|
+
}
|
|
35
|
+
/** True when the model has any authored SFD coordinate. */
|
|
36
|
+
export function hasAuthoredSfd(nodes) {
|
|
37
|
+
return nodes.some((v) => {
|
|
38
|
+
const s = objectMap(v.extra["sfd"]);
|
|
39
|
+
return !!s && (num(s["x"]) !== undefined || num(s["y"]) !== undefined);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function extraWithFlow(extra, flow) {
|
|
43
|
+
return { ...extra, flow: { from: flow.from, to: flow.to } };
|
|
44
|
+
}
|
|
45
|
+
export function extraWithoutFlow(extra) {
|
|
46
|
+
const next = { ...extra };
|
|
47
|
+
delete next["flow"];
|
|
48
|
+
return next;
|
|
49
|
+
}
|
|
50
|
+
export function extraWithSfdPosition(extra, x, y) {
|
|
51
|
+
return { ...extra, sfd: { x, y } };
|
|
52
|
+
}
|
|
53
|
+
/** The stock deltas this flow implies: +1 inflow, -1 outflow. */
|
|
54
|
+
export function flowTouches(flow, byId) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const spec = flowOf(flow);
|
|
57
|
+
if (spec) {
|
|
58
|
+
if (!isCloud(spec.from) && byId.get(spec.from)?.type === "stock") {
|
|
59
|
+
out.push({ stockId: spec.from, sign: -1 });
|
|
60
|
+
}
|
|
61
|
+
if (!isCloud(spec.to) && byId.get(spec.to)?.type === "stock") {
|
|
62
|
+
out.push({ stockId: spec.to, sign: 1 });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
// A present explicit block is authoritative. Malformed explicit topology
|
|
67
|
+
// must not silently fall through to a different legacy interpretation.
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(flow.extra, "flow"))
|
|
69
|
+
return out;
|
|
70
|
+
for (const l of flow.links) {
|
|
71
|
+
const t = byId.get(l.to);
|
|
72
|
+
if (t?.type !== "stock")
|
|
73
|
+
continue;
|
|
74
|
+
if (l.indirect)
|
|
75
|
+
continue;
|
|
76
|
+
if (l.polarity !== "+" && l.polarity !== "-")
|
|
77
|
+
continue;
|
|
78
|
+
out.push({ stockId: l.to, sign: l.polarity === "-" ? -1 : 1 });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Material endpoints, using an authoritative stored block when present and
|
|
84
|
+
* legacy flow->stock inference only when that block is absent.
|
|
85
|
+
* Returns null when the flow cannot be represented as exactly one from/to pair.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveFlowSpec(flow, byId) {
|
|
88
|
+
const stored = flowOf(flow);
|
|
89
|
+
if (stored) {
|
|
90
|
+
return validateFlowEndpoints(stored.from, stored.to, byId).ok ? stored : null;
|
|
91
|
+
}
|
|
92
|
+
if (Object.prototype.hasOwnProperty.call(flow.extra, "flow"))
|
|
93
|
+
return null;
|
|
94
|
+
for (const link of flow.links) {
|
|
95
|
+
if (byId.get(link.to)?.type !== "stock")
|
|
96
|
+
continue;
|
|
97
|
+
if (link.indirect || (link.polarity !== "+" && link.polarity !== "-"))
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const touched = flowTouches(flow, byId);
|
|
101
|
+
const inflows = touched.filter((t) => t.sign > 0).map((t) => t.stockId);
|
|
102
|
+
const outflows = touched.filter((t) => t.sign < 0).map((t) => t.stockId);
|
|
103
|
+
let inferred = null;
|
|
104
|
+
if (inflows.length === 1 && outflows.length === 1) {
|
|
105
|
+
inferred = { from: outflows[0], to: inflows[0] };
|
|
106
|
+
}
|
|
107
|
+
else if (inflows.length === 1 && outflows.length === 0) {
|
|
108
|
+
inferred = { from: SOURCE_CLOUD, to: inflows[0] };
|
|
109
|
+
}
|
|
110
|
+
else if (outflows.length === 1 && inflows.length === 0) {
|
|
111
|
+
inferred = { from: outflows[0], to: SINK_CLOUD };
|
|
112
|
+
}
|
|
113
|
+
return inferred && validateFlowEndpoints(inferred.from, inferred.to, byId).ok
|
|
114
|
+
? inferred
|
|
115
|
+
: null;
|
|
116
|
+
}
|
|
117
|
+
export function validateFlowEndpoints(from, to, byId) {
|
|
118
|
+
const bad = (end, isFrom) => {
|
|
119
|
+
if (isCloud(end)) {
|
|
120
|
+
if (isFrom && end !== SOURCE_CLOUD)
|
|
121
|
+
return "From cloud must be ~source.";
|
|
122
|
+
if (!isFrom && end !== SINK_CLOUD)
|
|
123
|
+
return "To cloud must be ~sink.";
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const v = byId.get(end);
|
|
127
|
+
if (!v)
|
|
128
|
+
return `Endpoint not found: ${end}`;
|
|
129
|
+
if (v.type !== "stock")
|
|
130
|
+
return `Endpoint must be a stock: ${v.label || v.id}`;
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
const fromErr = bad(from, true);
|
|
134
|
+
if (fromErr)
|
|
135
|
+
return { ok: false, error: fromErr };
|
|
136
|
+
const toErr = bad(to, false);
|
|
137
|
+
if (toErr)
|
|
138
|
+
return { ok: false, error: toErr };
|
|
139
|
+
if (from === to)
|
|
140
|
+
return { ok: false, error: "Flow endpoints must differ." };
|
|
141
|
+
if (isCloud(from) && isCloud(to)) {
|
|
142
|
+
return { ok: false, error: "A flow must touch at least one stock." };
|
|
143
|
+
}
|
|
144
|
+
return { ok: true };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* In SFD mode, material flow->stock links are represented by pipes. Remaining
|
|
148
|
+
* links, including auxiliaries feeding a flow's rate, stay information connectors.
|
|
149
|
+
*/
|
|
150
|
+
export function isMaterialLink(source, link, byId) {
|
|
151
|
+
if (source.type !== "flow")
|
|
152
|
+
return false;
|
|
153
|
+
const spec = resolveFlowSpec(source, byId);
|
|
154
|
+
if (!spec)
|
|
155
|
+
return false;
|
|
156
|
+
if (Object.prototype.hasOwnProperty.call(source.extra, "flow")) {
|
|
157
|
+
return link.to === spec.from || link.to === spec.to;
|
|
158
|
+
}
|
|
159
|
+
if (link.indirect || (link.polarity !== "+" && link.polarity !== "-"))
|
|
160
|
+
return false;
|
|
161
|
+
return ((link.to === spec.from && link.polarity === "-") ||
|
|
162
|
+
(link.to === spec.to && link.polarity === "+"));
|
|
163
|
+
}
|
|
164
|
+
/** Deterministic SFD fallback layout, ported from loopy core `sfd_layout.dart`. */
|
|
165
|
+
export function computeSfdLayout(vars, byId) {
|
|
166
|
+
const cmp = (a, b) => {
|
|
167
|
+
const byLabel = a.label.localeCompare(b.label);
|
|
168
|
+
return byLabel !== 0 ? byLabel : a.id.localeCompare(b.id);
|
|
169
|
+
};
|
|
170
|
+
const stocks = vars.filter((v) => v.type === "stock").sort(cmp);
|
|
171
|
+
const flows = vars.filter((v) => v.type === "flow").sort(cmp);
|
|
172
|
+
const auxes = vars.filter((v) => v.type === "auxiliary").sort(cmp);
|
|
173
|
+
const stockIds = stocks.map((s) => s.id);
|
|
174
|
+
const stockSet = new Set(stockIds);
|
|
175
|
+
const cmpId = (x, y) => {
|
|
176
|
+
const a = byId.get(x);
|
|
177
|
+
const b = byId.get(y);
|
|
178
|
+
return a && b ? cmp(a, b) : x.localeCompare(y);
|
|
179
|
+
};
|
|
180
|
+
const out = new Map(stockIds.map((id) => [id, new Set()]));
|
|
181
|
+
const inn = new Map(stockIds.map((id) => [id, new Set()]));
|
|
182
|
+
for (const f of flows) {
|
|
183
|
+
const spec = resolveFlowSpec(f, byId);
|
|
184
|
+
if (!spec)
|
|
185
|
+
continue;
|
|
186
|
+
if (!stockSet.has(spec.from) || !stockSet.has(spec.to))
|
|
187
|
+
continue;
|
|
188
|
+
if (spec.from === spec.to)
|
|
189
|
+
continue;
|
|
190
|
+
out.get(spec.from)?.add(spec.to);
|
|
191
|
+
inn.get(spec.to)?.add(spec.from);
|
|
192
|
+
}
|
|
193
|
+
const sortedOut = new Map();
|
|
194
|
+
for (const id of stockIds)
|
|
195
|
+
sortedOut.set(id, [...(out.get(id) ?? [])].sort(cmpId));
|
|
196
|
+
const white = 0;
|
|
197
|
+
const grey = 1;
|
|
198
|
+
const black = 2;
|
|
199
|
+
const color = new Map(stockIds.map((id) => [id, white]));
|
|
200
|
+
const backEdges = new Set();
|
|
201
|
+
const edgeKey = (from, to) => `${from}\u001f${to}`;
|
|
202
|
+
const visit = (u) => {
|
|
203
|
+
color.set(u, grey);
|
|
204
|
+
for (const v of sortedOut.get(u) ?? []) {
|
|
205
|
+
const c = color.get(v) ?? white;
|
|
206
|
+
if (c === grey)
|
|
207
|
+
backEdges.add(edgeKey(u, v));
|
|
208
|
+
else if (c === white)
|
|
209
|
+
visit(v);
|
|
210
|
+
}
|
|
211
|
+
color.set(u, black);
|
|
212
|
+
};
|
|
213
|
+
for (const id of stockIds)
|
|
214
|
+
if ((color.get(id) ?? white) === white)
|
|
215
|
+
visit(id);
|
|
216
|
+
const dagPreds = new Map();
|
|
217
|
+
for (const id of stockIds) {
|
|
218
|
+
dagPreds.set(id, [...(inn.get(id) ?? [])].filter((p) => !backEdges.has(edgeKey(p, id))).sort(cmpId));
|
|
219
|
+
}
|
|
220
|
+
const col = new Map();
|
|
221
|
+
const onStack = new Set();
|
|
222
|
+
const columnOf = (u) => {
|
|
223
|
+
const cached = col.get(u);
|
|
224
|
+
if (cached !== undefined)
|
|
225
|
+
return cached;
|
|
226
|
+
if (onStack.has(u))
|
|
227
|
+
return 0;
|
|
228
|
+
onStack.add(u);
|
|
229
|
+
let c = 0;
|
|
230
|
+
for (const p of dagPreds.get(u) ?? [])
|
|
231
|
+
c = Math.max(c, columnOf(p) + 1);
|
|
232
|
+
onStack.delete(u);
|
|
233
|
+
col.set(u, c);
|
|
234
|
+
return c;
|
|
235
|
+
};
|
|
236
|
+
for (const id of stockIds)
|
|
237
|
+
columnOf(id);
|
|
238
|
+
const maxCol = Math.max(0, ...col.values());
|
|
239
|
+
const byColumn = new Map();
|
|
240
|
+
for (let c = 0; c <= maxCol; c++)
|
|
241
|
+
byColumn.set(c, []);
|
|
242
|
+
for (const id of stockIds)
|
|
243
|
+
byColumn.get(col.get(id) ?? 0)?.push(id);
|
|
244
|
+
const row = new Map();
|
|
245
|
+
for (let c = 0; c <= maxCol; c++) {
|
|
246
|
+
const ids = (byColumn.get(c) ?? []).sort(cmpId);
|
|
247
|
+
const used = new Set();
|
|
248
|
+
for (const id of ids) {
|
|
249
|
+
const preds = dagPreds.get(id) ?? [];
|
|
250
|
+
let r;
|
|
251
|
+
if (preds.length === 1) {
|
|
252
|
+
const pr = row.get(preds[0]);
|
|
253
|
+
if (pr !== undefined && !used.has(pr))
|
|
254
|
+
r = pr;
|
|
255
|
+
}
|
|
256
|
+
if (r === undefined) {
|
|
257
|
+
r = 0;
|
|
258
|
+
while (used.has(r))
|
|
259
|
+
r++;
|
|
260
|
+
}
|
|
261
|
+
used.add(r);
|
|
262
|
+
row.set(id, r);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const result = new Map();
|
|
266
|
+
for (const id of stockIds) {
|
|
267
|
+
result.set(id, { x: (col.get(id) ?? 0) * 220, y: (row.get(id) ?? 0) * 130 });
|
|
268
|
+
}
|
|
269
|
+
for (const f of flows) {
|
|
270
|
+
const spec = resolveFlowSpec(f, byId);
|
|
271
|
+
let valve;
|
|
272
|
+
if (spec) {
|
|
273
|
+
const fromPos = isCloud(spec.from) ? undefined : result.get(spec.from);
|
|
274
|
+
const toPos = isCloud(spec.to) ? undefined : result.get(spec.to);
|
|
275
|
+
if (fromPos && toPos)
|
|
276
|
+
valve = { x: (fromPos.x + toPos.x) / 2, y: (fromPos.y + toPos.y) / 2 };
|
|
277
|
+
else if (toPos)
|
|
278
|
+
valve = { x: toPos.x - 80, y: toPos.y };
|
|
279
|
+
else if (fromPos)
|
|
280
|
+
valve = { x: fromPos.x + 80, y: fromPos.y };
|
|
281
|
+
}
|
|
282
|
+
if (!valve) {
|
|
283
|
+
const touched = flowTouches(f, byId).map((t) => result.get(t.stockId)).filter((p) => !!p);
|
|
284
|
+
valve = touched.length === 0
|
|
285
|
+
? { x: 0, y: 0 }
|
|
286
|
+
: {
|
|
287
|
+
x: touched.reduce((sum, p) => sum + p.x, 0) / touched.length,
|
|
288
|
+
y: touched.reduce((sum, p) => sum + p.y, 0) / touched.length,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
result.set(f.id, valve);
|
|
292
|
+
}
|
|
293
|
+
const auxIds = new Set(auxes.map((v) => v.id));
|
|
294
|
+
const incoming = new Map(auxes.map((v) => [v.id, new Set()]));
|
|
295
|
+
for (const v of vars) {
|
|
296
|
+
if (auxIds.has(v.id))
|
|
297
|
+
continue;
|
|
298
|
+
for (const l of v.links)
|
|
299
|
+
incoming.get(l.to)?.add(v.id);
|
|
300
|
+
}
|
|
301
|
+
const minStockY = stockIds.length === 0
|
|
302
|
+
? 0
|
|
303
|
+
: Math.min(...stockIds.map((id) => result.get(id)?.y ?? 0));
|
|
304
|
+
const auxY = minStockY - 110;
|
|
305
|
+
for (const v of auxes) {
|
|
306
|
+
const anchors = new Set();
|
|
307
|
+
for (const l of v.links) {
|
|
308
|
+
if (result.has(l.to) && !auxIds.has(l.to))
|
|
309
|
+
anchors.add(l.to);
|
|
310
|
+
}
|
|
311
|
+
for (const id of incoming.get(v.id) ?? [])
|
|
312
|
+
anchors.add(id);
|
|
313
|
+
const xs = [...anchors].sort(cmpId).map((id) => result.get(id)?.x).filter((x) => x !== undefined);
|
|
314
|
+
result.set(v.id, { x: xs.length === 0 ? 0 : xs.reduce((a, b) => a + b, 0) / xs.length, y: auxY });
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
export function sfdPositionsFor(nodes) {
|
|
319
|
+
return new Map(nodes.map((n) => [n.id, sfdPositionOf(n) ?? { x: n.x, y: n.y }]));
|
|
320
|
+
}
|
package/dist/engine/specHash.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* the qualitative plugin never computes quant staleness.
|
|
15
15
|
*/
|
|
16
16
|
import { varTypeName } from "./types";
|
|
17
|
+
import { flowOf } from "./sfd";
|
|
17
18
|
/** Provenance value stored in a note's `source` key for plugin writes. */
|
|
18
19
|
export const WRITE_SOURCE_PLUGIN = "plugin";
|
|
19
20
|
const encoder = new TextEncoder();
|
|
@@ -35,6 +36,7 @@ export function fnv1a32(s) {
|
|
|
35
36
|
export function canonicalContent(v) {
|
|
36
37
|
const tags = [...v.tags].sort();
|
|
37
38
|
const links = [...v.links].sort((a, b) => (a.to < b.to ? -1 : a.to > b.to ? 1 : 0));
|
|
39
|
+
const flow = flowOf(v);
|
|
38
40
|
const linkPart = (l) => {
|
|
39
41
|
const base = `${l.to}|${l.polarity}|${l.delay ? 1 : 0}|${l.indirect ? 1 : 0}|${l.nonlinear ? 1 : 0}`;
|
|
40
42
|
const conf = l.confidence !== undefined ? `|c${l.confidence.toFixed(3)}` : "";
|
|
@@ -52,6 +54,8 @@ export function canonicalContent(v) {
|
|
|
52
54
|
...links.map(linkPart),
|
|
53
55
|
v.body.trim(),
|
|
54
56
|
];
|
|
57
|
+
if (flow)
|
|
58
|
+
parts.push(`flow:${flow.from}->${flow.to}`);
|
|
55
59
|
if ((v.shared ?? "").length > 0)
|
|
56
60
|
parts.push(v.shared);
|
|
57
61
|
return parts.join("\n");
|
package/dist/engine/types.d.ts
CHANGED
|
@@ -26,7 +26,8 @@ export declare enum LoopType {
|
|
|
26
26
|
*/
|
|
27
27
|
export interface VaultLink {
|
|
28
28
|
to: string;
|
|
29
|
-
|
|
29
|
+
/** `?` is preserved unknown-sign input and never participates in a loop. */
|
|
30
|
+
polarity: "+" | "-" | "?";
|
|
30
31
|
delay: boolean;
|
|
31
32
|
indirect: boolean;
|
|
32
33
|
nonlinear: boolean;
|
|
@@ -93,11 +94,61 @@ export interface ModelManifest {
|
|
|
93
94
|
export declare function toUtcIso(v: unknown): string | undefined;
|
|
94
95
|
export declare function manifestFromJson(j: Record<string, unknown>): ModelManifest;
|
|
95
96
|
export declare function manifestToJson(m: ModelManifest): Record<string, unknown>;
|
|
97
|
+
export type CanvasLoopLegKind = "causal" | "material";
|
|
98
|
+
/** One exact declared information connector in a resolved executable cycle. */
|
|
99
|
+
export interface CausalCanvasLoopLeg {
|
|
100
|
+
readonly kind: "causal";
|
|
101
|
+
readonly fromNodeId: string;
|
|
102
|
+
readonly toNodeId: string;
|
|
103
|
+
readonly edgeId: string;
|
|
104
|
+
readonly polarity: 1 | -1;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* One exact first-class flow/stock pipe leg in a resolved executable cycle.
|
|
108
|
+
* `cldEdgeId` is either the matching declared connector or the deterministic,
|
|
109
|
+
* non-persistent CLD projection that represents this material effect.
|
|
110
|
+
*/
|
|
111
|
+
export interface MaterialCanvasLoopLeg {
|
|
112
|
+
readonly kind: "material";
|
|
113
|
+
readonly fromNodeId: string;
|
|
114
|
+
readonly toNodeId: string;
|
|
115
|
+
readonly flowId: string;
|
|
116
|
+
readonly stockId: string;
|
|
117
|
+
readonly cldEdgeId: string;
|
|
118
|
+
readonly polarity: 1 | -1;
|
|
119
|
+
}
|
|
120
|
+
export type CanvasLoopLeg = CausalCanvasLoopLeg | MaterialCanvasLoopLeg;
|
|
121
|
+
/**
|
|
122
|
+
* A complete executable cycle resolved to exact visible canvas elements.
|
|
123
|
+
* Partial paths are never represented.
|
|
124
|
+
*/
|
|
125
|
+
export declare class CanvasLoopPath {
|
|
126
|
+
readonly legs: readonly CanvasLoopLeg[];
|
|
127
|
+
constructor(legs: readonly CanvasLoopLeg[]);
|
|
128
|
+
get hasMaterialLeg(): boolean;
|
|
129
|
+
}
|
|
130
|
+
/** Rotate a directed cycle to its lexicographically smallest rotation. */
|
|
131
|
+
export declare function canonicalDirectedCycle(nodeIds: Iterable<string>): string[];
|
|
132
|
+
/** Rotation-invariant and routing-sensitive identity for a directed cycle. */
|
|
133
|
+
export declare function directedCycleKey(nodeIds: Iterable<string>): string;
|
|
134
|
+
export type LoopIdentityMode = "qualitative" | "quantitative";
|
|
96
135
|
/** A detected feedback loop: variable ids in cycle order + R/B classification. */
|
|
97
136
|
export declare class DetectedLoop {
|
|
98
137
|
readonly nodeIds: string[];
|
|
99
138
|
readonly type: LoopType;
|
|
100
|
-
|
|
101
|
-
|
|
139
|
+
readonly canvasPath?: CanvasLoopPath | undefined;
|
|
140
|
+
readonly identityMode: LoopIdentityMode;
|
|
141
|
+
/** Multiple directed routes collapsed to this legacy qualitative key. */
|
|
142
|
+
readonly exactRouteAmbiguous: boolean;
|
|
143
|
+
constructor(nodeIds: string[], type: LoopType, canvasPath?: CanvasLoopPath | undefined, identityMode?: LoopIdentityMode,
|
|
144
|
+
/** Multiple directed routes collapsed to this legacy qualitative key. */
|
|
145
|
+
exactRouteAmbiguous?: boolean);
|
|
146
|
+
/** Rotation-invariant, routing-sensitive identity used for exact dedup. */
|
|
147
|
+
get exactKey(): string;
|
|
148
|
+
/**
|
|
149
|
+
* Qualitative loops retain the package's established numeric-type/sorted-id
|
|
150
|
+
* key. Quantitative-only badges use the exact directed key so distinct
|
|
151
|
+
* executable routings through the same nodes cannot collide.
|
|
152
|
+
*/
|
|
102
153
|
get key(): string;
|
|
103
154
|
}
|
package/dist/engine/types.js
CHANGED
|
@@ -38,9 +38,14 @@ export function normalizeBasis(v) {
|
|
|
38
38
|
}
|
|
39
39
|
export function linkFromMap(m) {
|
|
40
40
|
const pol = m["polarity"];
|
|
41
|
+
const hasPolarity = Object.prototype.hasOwnProperty.call(m, "polarity");
|
|
41
42
|
return {
|
|
42
43
|
to: String(m["to"]),
|
|
43
|
-
polarity: pol === "-" || pol === -1
|
|
44
|
+
polarity: pol === "-" || pol === -1
|
|
45
|
+
? "-"
|
|
46
|
+
: pol === "+" || pol === 1 || !hasPolarity
|
|
47
|
+
? "+"
|
|
48
|
+
: "?",
|
|
44
49
|
delay: m["delay"] === true,
|
|
45
50
|
indirect: m["indirect"] === true,
|
|
46
51
|
nonlinear: m["nonlinear"] === true,
|
|
@@ -141,15 +146,71 @@ export function manifestToJson(m) {
|
|
|
141
146
|
out["order"] = m.order;
|
|
142
147
|
return { ...out, ...m.extra };
|
|
143
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* A complete executable cycle resolved to exact visible canvas elements.
|
|
151
|
+
* Partial paths are never represented.
|
|
152
|
+
*/
|
|
153
|
+
export class CanvasLoopPath {
|
|
154
|
+
constructor(legs) {
|
|
155
|
+
this.legs = Object.freeze([...legs]);
|
|
156
|
+
}
|
|
157
|
+
get hasMaterialLeg() {
|
|
158
|
+
return this.legs.some((leg) => leg.kind === "material");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Rotate a directed cycle to its lexicographically smallest rotation. */
|
|
162
|
+
export function canonicalDirectedCycle(nodeIds) {
|
|
163
|
+
const nodes = [...nodeIds];
|
|
164
|
+
if (nodes.length > 1 && nodes[0] === nodes[nodes.length - 1])
|
|
165
|
+
nodes.pop();
|
|
166
|
+
if (nodes.length === 0)
|
|
167
|
+
return [];
|
|
168
|
+
const compareRotation = (a, b) => {
|
|
169
|
+
for (let offset = 0; offset < nodes.length; offset++) {
|
|
170
|
+
const left = nodes[(a + offset) % nodes.length];
|
|
171
|
+
const right = nodes[(b + offset) % nodes.length];
|
|
172
|
+
if (left < right)
|
|
173
|
+
return -1;
|
|
174
|
+
if (left > right)
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
};
|
|
179
|
+
let best = 0;
|
|
180
|
+
for (let candidate = 1; candidate < nodes.length; candidate++) {
|
|
181
|
+
if (compareRotation(candidate, best) < 0)
|
|
182
|
+
best = candidate;
|
|
183
|
+
}
|
|
184
|
+
return [...nodes.slice(best), ...nodes.slice(0, best)];
|
|
185
|
+
}
|
|
186
|
+
/** Rotation-invariant and routing-sensitive identity for a directed cycle. */
|
|
187
|
+
export function directedCycleKey(nodeIds) {
|
|
188
|
+
return canonicalDirectedCycle(nodeIds).map(encodeURIComponent).join(">");
|
|
189
|
+
}
|
|
144
190
|
/** A detected feedback loop: variable ids in cycle order + R/B classification. */
|
|
145
191
|
export class DetectedLoop {
|
|
146
|
-
constructor(nodeIds, type
|
|
192
|
+
constructor(nodeIds, type, canvasPath, identityMode = "qualitative",
|
|
193
|
+
/** Multiple directed routes collapsed to this legacy qualitative key. */
|
|
194
|
+
exactRouteAmbiguous = false) {
|
|
147
195
|
this.nodeIds = nodeIds;
|
|
148
196
|
this.type = type;
|
|
197
|
+
this.canvasPath = canvasPath;
|
|
198
|
+
this.identityMode = identityMode;
|
|
199
|
+
this.exactRouteAmbiguous = exactRouteAmbiguous;
|
|
200
|
+
}
|
|
201
|
+
/** Rotation-invariant, routing-sensitive identity used for exact dedup. */
|
|
202
|
+
get exactKey() {
|
|
203
|
+
const prefix = this.type === LoopType.reinforcing ? "R" : "B";
|
|
204
|
+
return `${prefix}:${directedCycleKey(this.nodeIds)}`;
|
|
149
205
|
}
|
|
150
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Qualitative loops retain the package's established numeric-type/sorted-id
|
|
208
|
+
* key. Quantitative-only badges use the exact directed key so distinct
|
|
209
|
+
* executable routings through the same nodes cannot collide.
|
|
210
|
+
*/
|
|
151
211
|
get key() {
|
|
152
|
-
|
|
153
|
-
|
|
212
|
+
if (this.identityMode === "quantitative")
|
|
213
|
+
return this.exactKey;
|
|
214
|
+
return `${this.type}:${[...this.nodeIds].sort().join("|")}`;
|
|
154
215
|
}
|
|
155
216
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/view/geometry.d.ts
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { DetectedLoop, VariableFile, VaultLink } from "../engine/types";
|
|
10
10
|
import { Bounds, Point } from "./camera";
|
|
11
|
+
export type DiagramViewMode = "cld" | "sfd";
|
|
12
|
+
/** Loops whose complete representation exists in the requested canvas view. */
|
|
13
|
+
export declare function loopsForMode(loops: DetectedLoop[], mode: DiagramViewMode): DetectedLoop[];
|
|
14
|
+
/** Keep a selected badge only when that exact loop exists in the next view. */
|
|
15
|
+
export declare function retainedLoopKeyForMode(loops: DetectedLoop[], selectedKey: string | null, mode: DiagramViewMode): string | null;
|
|
11
16
|
export interface NodeBox {
|
|
12
17
|
id: string;
|
|
13
18
|
cx: number;
|
|
@@ -21,6 +26,9 @@ export interface EdgeRef {
|
|
|
21
26
|
source: string;
|
|
22
27
|
target: string;
|
|
23
28
|
link: VaultLink;
|
|
29
|
+
/** View-only topology (for example a CLD material projection).
|
|
30
|
+
* Rendered normally, but never participates in edge hit/edit routing. */
|
|
31
|
+
renderOnly?: boolean;
|
|
24
32
|
}
|
|
25
33
|
export interface EdgeGeom extends EdgeRef {
|
|
26
34
|
points: Point[];
|
|
@@ -33,6 +41,18 @@ export interface EdgeGeom extends EdgeRef {
|
|
|
33
41
|
arrowTip: Point;
|
|
34
42
|
arrowAngle: number;
|
|
35
43
|
}
|
|
44
|
+
export interface SfdPipeGeom {
|
|
45
|
+
id: string;
|
|
46
|
+
flowId: string;
|
|
47
|
+
from: string;
|
|
48
|
+
to: string;
|
|
49
|
+
fromPoint: Point;
|
|
50
|
+
valvePoint: Point;
|
|
51
|
+
toPoint: Point;
|
|
52
|
+
fromCloud: Point | null;
|
|
53
|
+
toCloud: Point | null;
|
|
54
|
+
axisAngle: number;
|
|
55
|
+
}
|
|
36
56
|
/**
|
|
37
57
|
* Node (x,y) is the box center, matching the Dart painter + autoLayout.
|
|
38
58
|
*
|
|
@@ -43,8 +63,19 @@ export interface EdgeGeom extends EdgeRef {
|
|
|
43
63
|
* character-count estimate `layout.boxSize`/`autoLayout` use, so positions stay
|
|
44
64
|
* deterministic.
|
|
45
65
|
*/
|
|
46
|
-
export declare function buildNodeBoxes(nodes: VariableFile[], measure?: (label: string) => number): Map<string, NodeBox>;
|
|
66
|
+
export declare function buildNodeBoxes(nodes: VariableFile[], measure?: (label: string) => number, positions?: Map<string, Point>): Map<string, NodeBox>;
|
|
47
67
|
export declare function collectEdges(nodes: VariableFile[]): EdgeRef[];
|
|
68
|
+
export declare function collectInfoEdges(nodes: VariableFile[], mode: DiagramViewMode): EdgeRef[];
|
|
69
|
+
/**
|
|
70
|
+
* Minimum non-persistent causal projections needed to close fully resolved
|
|
71
|
+
* quantitative loops in CLD notation. A matching declared connector is already
|
|
72
|
+
* present in `collectEdges`; only legs whose resolver assigned a synthetic
|
|
73
|
+
* `cldEdgeId` are emitted here. These refs are `renderOnly`, so they cannot be
|
|
74
|
+
* selected, bowed, deleted, or serialized as authored causal links.
|
|
75
|
+
*/
|
|
76
|
+
export declare function collectCldMaterialProjectionEdges(nodes: VariableFile[], loops: DetectedLoop[]): EdgeRef[];
|
|
77
|
+
export declare function sfdRenderPositions(nodes: VariableFile[]): Map<string, Point>;
|
|
78
|
+
export declare function buildSfdPipeGeoms(nodes: VariableFile[], boxes: Map<string, NodeBox>): SfdPipeGeom[];
|
|
48
79
|
/**
|
|
49
80
|
* @param bowCache Per-edge frozen bow signs (keyed by edge id). Lone edges pick
|
|
50
81
|
* a bow side from the graph centroid **once** and reuse it; without this, a
|
|
@@ -66,6 +97,8 @@ export declare function computeBadges(loops: DetectedLoop[], boxes: Map<string,
|
|
|
66
97
|
* to the first. Used to highlight the loop when its badge is selected.
|
|
67
98
|
*/
|
|
68
99
|
export declare function loopEdgeIds(loop: DetectedLoop): Set<string>;
|
|
100
|
+
/** Exact material pipe legs (`flowId` + `stockId`) belonging to a resolved loop. */
|
|
101
|
+
export declare function loopPipeLegIds(loop: DetectedLoop): Set<string>;
|
|
69
102
|
export declare function hitNode(boxes: Map<string, NodeBox>, p: Point): string | null;
|
|
70
103
|
export declare function hitEdge(geoms: EdgeGeom[], p: Point, scale: number): string | null;
|
|
71
104
|
/**
|
|
@@ -81,3 +114,4 @@ export declare function hitBadge(badges: Map<string, Point>, p: Point, scale: nu
|
|
|
81
114
|
export declare function inConnectBand(box: NodeBox, p: Point, tol: number, gap?: number): boolean;
|
|
82
115
|
export declare function distToSegment(p: Point, a: Point, b: Point): number;
|
|
83
116
|
export declare function nodeBounds(boxes: Map<string, NodeBox>): Bounds;
|
|
117
|
+
export declare function sceneBounds(boxes: Map<string, NodeBox>, pipes?: SfdPipeGeom[]): Bounds;
|