@neoloopy/cld-canvas 0.1.4 → 0.1.7
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
package/dist/view/geometry.js
CHANGED
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
* a round arc, not a pinched parabola), trimmed to the node rims. Pure module —
|
|
7
7
|
* no `obsidian`, no canvas — so the math is unit-testable.
|
|
8
8
|
*/
|
|
9
|
+
import { materialPipeLegId } from "../engine/quantCanvasLoops";
|
|
10
|
+
import { isCloud, isMaterialLink, resolveFlowSpec, sfdPositionsFor, } from "../engine/sfd";
|
|
11
|
+
/** Loops whose complete representation exists in the requested canvas view. */
|
|
12
|
+
export function loopsForMode(loops, mode) {
|
|
13
|
+
return mode === "sfd"
|
|
14
|
+
? loops.filter((loop) => loop.canvasPath !== undefined &&
|
|
15
|
+
(loop.identityMode === "qualitative" || loop.canvasPath.hasMaterialLeg))
|
|
16
|
+
: loops;
|
|
17
|
+
}
|
|
18
|
+
/** Keep a selected badge only when that exact loop exists in the next view. */
|
|
19
|
+
export function retainedLoopKeyForMode(loops, selectedKey, mode) {
|
|
20
|
+
if (selectedKey === null)
|
|
21
|
+
return null;
|
|
22
|
+
const selected = loops.find((loop) => loop.key === selectedKey);
|
|
23
|
+
return selected && loopsForMode([selected], mode).length === 1
|
|
24
|
+
? selected.key
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
9
27
|
/**
|
|
10
28
|
* Node (x,y) is the box center, matching the Dart painter + autoLayout.
|
|
11
29
|
*
|
|
@@ -16,7 +34,7 @@
|
|
|
16
34
|
* character-count estimate `layout.boxSize`/`autoLayout` use, so positions stay
|
|
17
35
|
* deterministic.
|
|
18
36
|
*/
|
|
19
|
-
export function buildNodeBoxes(nodes, measure) {
|
|
37
|
+
export function buildNodeBoxes(nodes, measure, positions) {
|
|
20
38
|
const m = new Map();
|
|
21
39
|
for (const n of nodes) {
|
|
22
40
|
const label = n.label || n.id;
|
|
@@ -24,7 +42,8 @@ export function buildNodeBoxes(nodes, measure) {
|
|
|
24
42
|
const extra = n.type === "flow" ? 40 : 36;
|
|
25
43
|
const textW = measure ? measure(label) : label.length * 7.2;
|
|
26
44
|
const w = Math.max(60, textW + extra);
|
|
27
|
-
|
|
45
|
+
const p = positions?.get(n.id) ?? n;
|
|
46
|
+
m.set(n.id, { id: n.id, cx: p.x, cy: p.y, w, h, type: n.type });
|
|
28
47
|
}
|
|
29
48
|
return m;
|
|
30
49
|
}
|
|
@@ -37,6 +56,105 @@ export function collectEdges(nodes) {
|
|
|
37
56
|
}
|
|
38
57
|
return out;
|
|
39
58
|
}
|
|
59
|
+
export function collectInfoEdges(nodes, mode) {
|
|
60
|
+
if (mode === "cld")
|
|
61
|
+
return collectEdges(nodes);
|
|
62
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const n of nodes) {
|
|
65
|
+
for (const l of n.links) {
|
|
66
|
+
if (isMaterialLink(n, l, byId))
|
|
67
|
+
continue;
|
|
68
|
+
out.push({ id: `${n.id}__${l.to}`, source: n.id, target: l.to, link: l });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Minimum non-persistent causal projections needed to close fully resolved
|
|
75
|
+
* quantitative loops in CLD notation. A matching declared connector is already
|
|
76
|
+
* present in `collectEdges`; only legs whose resolver assigned a synthetic
|
|
77
|
+
* `cldEdgeId` are emitted here. These refs are `renderOnly`, so they cannot be
|
|
78
|
+
* selected, bowed, deleted, or serialized as authored causal links.
|
|
79
|
+
*/
|
|
80
|
+
export function collectCldMaterialProjectionEdges(nodes, loops) {
|
|
81
|
+
const persistedIds = new Set(collectEdges(nodes).map((edge) => edge.id));
|
|
82
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
83
|
+
const emitted = new Set();
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const loop of loops) {
|
|
86
|
+
for (const leg of loop.canvasPath?.legs ?? []) {
|
|
87
|
+
if (leg.kind !== "material")
|
|
88
|
+
continue;
|
|
89
|
+
if (persistedIds.has(leg.cldEdgeId) || !emitted.add(leg.cldEdgeId))
|
|
90
|
+
continue;
|
|
91
|
+
if (!byId.has(leg.flowId) || !byId.has(leg.stockId))
|
|
92
|
+
continue;
|
|
93
|
+
out.push({
|
|
94
|
+
id: leg.cldEdgeId,
|
|
95
|
+
source: leg.flowId,
|
|
96
|
+
target: leg.stockId,
|
|
97
|
+
link: {
|
|
98
|
+
to: leg.stockId,
|
|
99
|
+
polarity: leg.polarity < 0 ? "-" : "+",
|
|
100
|
+
delay: false,
|
|
101
|
+
indirect: false,
|
|
102
|
+
nonlinear: false,
|
|
103
|
+
},
|
|
104
|
+
renderOnly: true,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
export function sfdRenderPositions(nodes) {
|
|
111
|
+
return sfdPositionsFor(nodes);
|
|
112
|
+
}
|
|
113
|
+
export function buildSfdPipeGeoms(nodes, boxes) {
|
|
114
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const flow of nodes) {
|
|
117
|
+
if (flow.type !== "flow")
|
|
118
|
+
continue;
|
|
119
|
+
const spec = resolveFlowSpec(flow, byId);
|
|
120
|
+
if (!spec)
|
|
121
|
+
continue;
|
|
122
|
+
const valveBox = boxes.get(flow.id);
|
|
123
|
+
if (!valveBox)
|
|
124
|
+
continue;
|
|
125
|
+
const valvePoint = { x: valveBox.cx, y: valveBox.cy };
|
|
126
|
+
const fromStock = !isCloud(spec.from) && byId.get(spec.from)?.type === "stock" ? boxes.get(spec.from) : undefined;
|
|
127
|
+
const toStock = !isCloud(spec.to) && byId.get(spec.to)?.type === "stock" ? boxes.get(spec.to) : undefined;
|
|
128
|
+
const fromReal = fromStock ? { x: fromStock.cx, y: fromStock.cy } : null;
|
|
129
|
+
const toReal = toStock ? { x: toStock.cx, y: toStock.cy } : null;
|
|
130
|
+
const fromCloud = fromStock ? null : awayCloud(valvePoint, toReal, -1);
|
|
131
|
+
const toCloud = toStock ? null : awayCloud(valvePoint, fromReal, 1);
|
|
132
|
+
const fromPoint = fromStock ? rimPoint(fromStock, valvePoint) : fromCloud;
|
|
133
|
+
const toPoint = toStock ? rimPoint(toStock, valvePoint) : toCloud;
|
|
134
|
+
out.push({
|
|
135
|
+
id: `pipe__${flow.id}`,
|
|
136
|
+
flowId: flow.id,
|
|
137
|
+
from: spec.from,
|
|
138
|
+
to: spec.to,
|
|
139
|
+
fromPoint,
|
|
140
|
+
valvePoint,
|
|
141
|
+
toPoint,
|
|
142
|
+
fromCloud,
|
|
143
|
+
toCloud,
|
|
144
|
+
axisAngle: Math.atan2(toPoint.y - fromPoint.y, toPoint.x - fromPoint.x),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function awayCloud(flow, other, sign) {
|
|
150
|
+
if (other) {
|
|
151
|
+
const dx = flow.x - other.x;
|
|
152
|
+
const dy = flow.y - other.y;
|
|
153
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
154
|
+
return { x: flow.x + (dx / len) * 80, y: flow.y + (dy / len) * 80 };
|
|
155
|
+
}
|
|
156
|
+
return { x: flow.x + sign * 80, y: flow.y };
|
|
157
|
+
}
|
|
40
158
|
/**
|
|
41
159
|
* @param bowCache Per-edge frozen bow signs (keyed by edge id). Lone edges pick
|
|
42
160
|
* a bow side from the graph centroid **once** and reuse it; without this, a
|
|
@@ -46,8 +164,20 @@ export function collectEdges(nodes) {
|
|
|
46
164
|
export function buildEdgeGeoms(edges, boxes, bowCache) {
|
|
47
165
|
const present = edges.filter((e) => boxes.has(e.source) && boxes.has(e.target));
|
|
48
166
|
const dirSet = new Set(present.map((e) => `${e.source}>${e.target}`));
|
|
167
|
+
const explicitBowSigns = new Map();
|
|
168
|
+
for (const edge of present) {
|
|
169
|
+
const curvature = edge.link.curvature;
|
|
170
|
+
if (curvature === undefined || !Number.isFinite(curvature) || curvature === 0)
|
|
171
|
+
continue;
|
|
172
|
+
const key = `${edge.source}>${edge.target}`;
|
|
173
|
+
const sign = Math.sign(curvature);
|
|
174
|
+
const previous = explicitBowSigns.get(key);
|
|
175
|
+
// Duplicate authored connectors with conflicting bows do not select the
|
|
176
|
+
// automatic reciprocal's side. The normal default remains deterministic.
|
|
177
|
+
explicitBowSigns.set(key, previous === undefined || previous === sign ? sign : 0);
|
|
178
|
+
}
|
|
49
179
|
const centroid = centroidOf(boxes);
|
|
50
|
-
return present.map((e) => geomFor(e, boxes, dirSet, centroid, bowCache));
|
|
180
|
+
return present.map((e) => geomFor(e, boxes, dirSet, centroid, bowCache, explicitBowSigns.get(`${e.target}>${e.source}`)));
|
|
51
181
|
}
|
|
52
182
|
function centroidOf(boxes) {
|
|
53
183
|
let sx = 0;
|
|
@@ -60,7 +190,7 @@ function centroidOf(boxes) {
|
|
|
60
190
|
}
|
|
61
191
|
return n === 0 ? { x: 0, y: 0 } : { x: sx / n, y: sy / n };
|
|
62
192
|
}
|
|
63
|
-
function geomFor(e, boxes, dirSet, centroid, bowCache) {
|
|
193
|
+
function geomFor(e, boxes, dirSet, centroid, bowCache, reverseExplicitBowSign) {
|
|
64
194
|
const a = boxes.get(e.source);
|
|
65
195
|
const b = boxes.get(e.target);
|
|
66
196
|
if (e.source === e.target)
|
|
@@ -82,9 +212,10 @@ function geomFor(e, boxes, dirSet, centroid, bowCache) {
|
|
|
82
212
|
bow = e.link.curvature;
|
|
83
213
|
}
|
|
84
214
|
else if (dirSet.has(`${e.target}>${e.source}`)) {
|
|
85
|
-
// Reciprocal pair
|
|
86
|
-
//
|
|
87
|
-
|
|
215
|
+
// Reciprocal pair: matching signed bows split across the opposite chord
|
|
216
|
+
// normals. When the reverse connector was hand-bowed, mirror its sign so
|
|
217
|
+
// this automatic connector cannot collapse onto the authored curve.
|
|
218
|
+
bow = mag * (reverseExplicitBowSign === -1 ? -1 : 1);
|
|
88
219
|
}
|
|
89
220
|
else {
|
|
90
221
|
// Lone edge — bow away from the graph centroid so loops read as circles,
|
|
@@ -315,6 +446,9 @@ export function computeBadges(loops, boxes, overrides) {
|
|
|
315
446
|
* to the first. Used to highlight the loop when its badge is selected.
|
|
316
447
|
*/
|
|
317
448
|
export function loopEdgeIds(loop) {
|
|
449
|
+
if (loop.canvasPath) {
|
|
450
|
+
return new Set(loop.canvasPath.legs.map((leg) => leg.kind === "causal" ? leg.edgeId : leg.cldEdgeId));
|
|
451
|
+
}
|
|
318
452
|
const ids = loop.nodeIds;
|
|
319
453
|
const out = new Set();
|
|
320
454
|
for (let i = 0; i < ids.length; i++) {
|
|
@@ -322,6 +456,14 @@ export function loopEdgeIds(loop) {
|
|
|
322
456
|
}
|
|
323
457
|
return out;
|
|
324
458
|
}
|
|
459
|
+
/** Exact material pipe legs (`flowId` + `stockId`) belonging to a resolved loop. */
|
|
460
|
+
export function loopPipeLegIds(loop) {
|
|
461
|
+
if (!loop.canvasPath)
|
|
462
|
+
return new Set();
|
|
463
|
+
return new Set(loop.canvasPath.legs
|
|
464
|
+
.filter((leg) => leg.kind === "material")
|
|
465
|
+
.map((leg) => materialPipeLegId(leg.flowId, leg.stockId)));
|
|
466
|
+
}
|
|
325
467
|
// ---- hit testing -----------------------------------------------------------
|
|
326
468
|
export function hitNode(boxes, p) {
|
|
327
469
|
const arr = [...boxes.values()];
|
|
@@ -336,6 +478,8 @@ export function hitEdge(geoms, p, scale) {
|
|
|
336
478
|
let best = null;
|
|
337
479
|
let bestD = tol;
|
|
338
480
|
for (const g of geoms) {
|
|
481
|
+
if (g.renderOnly)
|
|
482
|
+
continue;
|
|
339
483
|
for (let i = 0; i + 1 < g.points.length; i++) {
|
|
340
484
|
const d = distToSegment(p, g.points[i], g.points[i + 1]);
|
|
341
485
|
if (d < bestD) {
|
|
@@ -354,6 +498,8 @@ export function hitEdge(geoms, p, scale) {
|
|
|
354
498
|
* pixel-perfect second press on the line.
|
|
355
499
|
*/
|
|
356
500
|
export function nearSelectedEdge(g, p, scale) {
|
|
501
|
+
if (g.renderOnly)
|
|
502
|
+
return false;
|
|
357
503
|
if (Math.hypot(g.mid.x - p.x, g.mid.y - p.y) < 26 / scale)
|
|
358
504
|
return true;
|
|
359
505
|
let best = Infinity;
|
|
@@ -406,3 +552,24 @@ export function nodeBounds(boxes) {
|
|
|
406
552
|
}
|
|
407
553
|
return { minX, minY, maxX, maxY };
|
|
408
554
|
}
|
|
555
|
+
export function sceneBounds(boxes, pipes = []) {
|
|
556
|
+
const bb = nodeBounds(boxes);
|
|
557
|
+
let { minX, minY, maxX, maxY } = bb;
|
|
558
|
+
for (const pipe of pipes) {
|
|
559
|
+
for (const p of [
|
|
560
|
+
pipe.fromPoint,
|
|
561
|
+
pipe.valvePoint,
|
|
562
|
+
pipe.toPoint,
|
|
563
|
+
pipe.fromCloud,
|
|
564
|
+
pipe.toCloud,
|
|
565
|
+
]) {
|
|
566
|
+
if (!p)
|
|
567
|
+
continue;
|
|
568
|
+
minX = Math.min(minX, p.x - 24);
|
|
569
|
+
minY = Math.min(minY, p.y - 18);
|
|
570
|
+
maxX = Math.max(maxX, p.x + 24);
|
|
571
|
+
maxY = Math.max(maxY, p.y + 18);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { minX, minY, maxX, maxY };
|
|
575
|
+
}
|
package/dist/view/painter.d.ts
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Camera, Point } from "./camera";
|
|
12
12
|
import { Theme } from "./theme";
|
|
13
|
-
import { EdgeGeom, NodeBox } from "./geometry";
|
|
13
|
+
import { DiagramViewMode, EdgeGeom, NodeBox, SfdPipeGeom } from "./geometry";
|
|
14
14
|
import { DetectedLoop, LoopType, VariableFile } from "../engine/types";
|
|
15
15
|
export interface Scene {
|
|
16
|
+
mode: DiagramViewMode;
|
|
16
17
|
nodes: VariableFile[];
|
|
17
18
|
boxes: Map<string, NodeBox>;
|
|
18
19
|
edges: EdgeGeom[];
|
|
20
|
+
pipes: SfdPipeGeom[];
|
|
19
21
|
loops: DetectedLoop[];
|
|
20
22
|
labels: Map<string, string>;
|
|
21
23
|
badges: Map<string, Point>;
|
|
@@ -23,9 +25,16 @@ export interface Scene {
|
|
|
23
25
|
/** A selected loop's members, so the painter can spotlight just that cycle. */
|
|
24
26
|
export interface LoopHighlight {
|
|
25
27
|
edgeIds: Set<string>;
|
|
28
|
+
/** First-class SFD material legs, keyed by exact flow + stock identity. */
|
|
29
|
+
pipeLegIds: Set<string>;
|
|
26
30
|
nodeIds: Set<string>;
|
|
27
31
|
type: LoopType;
|
|
28
32
|
}
|
|
33
|
+
/** One coherent selection payload used by plugin and Publish controllers. */
|
|
34
|
+
export declare function loopHighlightFor(loop: DetectedLoop | null): LoopHighlight | null;
|
|
35
|
+
export type MaterialPipeLegVisualState = "normal" | "highlighted" | "dimmed";
|
|
36
|
+
/** Exact per-leg SFD state; a transfer pipe's unrelated half can recede. */
|
|
37
|
+
export declare function materialPipeLegVisualState(flowId: string, stockId: string | null, highlight: LoopHighlight | null): MaterialPipeLegVisualState;
|
|
29
38
|
export interface PaintUi {
|
|
30
39
|
cssWidth: number;
|
|
31
40
|
cssHeight: number;
|
package/dist/view/painter.js
CHANGED
|
@@ -9,7 +9,30 @@
|
|
|
9
9
|
* quantitative detail lives in the ƒx node-menu modal, not on the canvas.
|
|
10
10
|
*/
|
|
11
11
|
import { groupSwatch, swatchBorder, swatchFill, swatchInk, withAlpha } from "./theme";
|
|
12
|
+
import { loopEdgeIds, loopPipeLegIds, } from "./geometry";
|
|
12
13
|
import { LoopType } from "../engine/types";
|
|
14
|
+
import { isCloud } from "../engine/sfd";
|
|
15
|
+
import { materialPipeLegId } from "../engine/quantCanvasLoops";
|
|
16
|
+
/** One coherent selection payload used by plugin and Publish controllers. */
|
|
17
|
+
export function loopHighlightFor(loop) {
|
|
18
|
+
return loop
|
|
19
|
+
? {
|
|
20
|
+
edgeIds: loopEdgeIds(loop),
|
|
21
|
+
pipeLegIds: loopPipeLegIds(loop),
|
|
22
|
+
nodeIds: new Set(loop.nodeIds),
|
|
23
|
+
type: loop.type,
|
|
24
|
+
}
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
27
|
+
/** Exact per-leg SFD state; a transfer pipe's unrelated half can recede. */
|
|
28
|
+
export function materialPipeLegVisualState(flowId, stockId, highlight) {
|
|
29
|
+
if (!highlight)
|
|
30
|
+
return "normal";
|
|
31
|
+
if (stockId && highlight.pipeLegIds.has(materialPipeLegId(flowId, stockId))) {
|
|
32
|
+
return "highlighted";
|
|
33
|
+
}
|
|
34
|
+
return "dimmed";
|
|
35
|
+
}
|
|
13
36
|
const EDGE_WIDTH = [1.6, 2.5, 3.4];
|
|
14
37
|
/**
|
|
15
38
|
* Canvas label font. The desktop app renders its canvas text in Helvetica Neue
|
|
@@ -36,13 +59,17 @@ export function paint(ctx, scene, camera, theme, ui) {
|
|
|
36
59
|
ctx.translate(camera.tx, camera.ty);
|
|
37
60
|
ctx.scale(camera.scale, camera.scale);
|
|
38
61
|
const hl = ui.loopHighlight;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
if (scene.mode === "sfd")
|
|
63
|
+
paintSfd(ctx, scene, theme, ui, hl);
|
|
64
|
+
else {
|
|
65
|
+
for (const g of scene.edges) {
|
|
66
|
+
drawEdge(ctx, g, theme, g.id === ui.selectedEdgeId, hl, ui.flowPhase);
|
|
67
|
+
}
|
|
68
|
+
for (const n of scene.nodes) {
|
|
69
|
+
const box = scene.boxes.get(n.id);
|
|
70
|
+
if (box) {
|
|
71
|
+
drawNode(ctx, box, n, theme, n.id === ui.selectedNodeId, ui.liveNodeIds.has(n.id), hl ? !hl.nodeIds.has(n.id) : false, n.id === ui.selectedNodeId ? ui.pulsePhase : 0);
|
|
72
|
+
}
|
|
46
73
|
}
|
|
47
74
|
}
|
|
48
75
|
for (const lp of scene.loops) {
|
|
@@ -57,6 +84,30 @@ export function paint(ctx, scene, camera, theme, ui) {
|
|
|
57
84
|
drawLinkPreview(ctx, { x: box.cx, y: box.cy }, ui.linkPreview.to, theme);
|
|
58
85
|
}
|
|
59
86
|
}
|
|
87
|
+
function paintSfd(ctx, scene, theme, ui, hl) {
|
|
88
|
+
const pipeByFlow = new Map(scene.pipes.map((p) => [p.flowId, p]));
|
|
89
|
+
for (const p of scene.pipes)
|
|
90
|
+
drawSfdPipe(ctx, p, scene, theme, hl, ui.flowPhase);
|
|
91
|
+
for (const g of scene.edges) {
|
|
92
|
+
drawEdge(ctx, g, theme, g.id === ui.selectedEdgeId, hl, ui.flowPhase);
|
|
93
|
+
}
|
|
94
|
+
for (const n of scene.nodes) {
|
|
95
|
+
if (n.type === "flow")
|
|
96
|
+
continue;
|
|
97
|
+
const box = scene.boxes.get(n.id);
|
|
98
|
+
if (!box)
|
|
99
|
+
continue;
|
|
100
|
+
drawNode(ctx, box, n, theme, n.id === ui.selectedNodeId, ui.liveNodeIds.has(n.id), hl ? !hl.nodeIds.has(n.id) : false, n.id === ui.selectedNodeId ? ui.pulsePhase : 0);
|
|
101
|
+
}
|
|
102
|
+
for (const n of scene.nodes) {
|
|
103
|
+
if (n.type !== "flow")
|
|
104
|
+
continue;
|
|
105
|
+
const box = scene.boxes.get(n.id);
|
|
106
|
+
if (!box)
|
|
107
|
+
continue;
|
|
108
|
+
drawSfdValve(ctx, box, n, pipeByFlow.get(n.id), theme, n.id === ui.selectedNodeId, ui.liveNodeIds.has(n.id), n.id === ui.selectedNodeId ? ui.pulsePhase : 0, hl ? !hl.nodeIds.has(n.id) : false);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
60
111
|
function drawGrid(ctx, camera, theme, ui) {
|
|
61
112
|
if (camera.scale < 0.25)
|
|
62
113
|
return;
|
|
@@ -205,23 +256,200 @@ function drawSubsystemMark(ctx, cx, cy, theme) {
|
|
|
205
256
|
ctx.stroke();
|
|
206
257
|
}
|
|
207
258
|
function drawValve(ctx, cx, cy, theme) {
|
|
208
|
-
// Stock-and-flow valve: two triangles meeting tip-to-tip
|
|
209
|
-
//
|
|
210
|
-
//
|
|
259
|
+
// Stock-and-flow valve: two triangles meeting tip-to-tip as a vertical
|
|
260
|
+
// hourglass (⧗) — bases horizontal at top and bottom, apexes pinching at the
|
|
261
|
+
// center so the horizontal flow line threads the waist. Each sub-path must be
|
|
262
|
+
// closed so its outer base edge is drawn — otherwise the four spokes read as
|
|
263
|
+
// an ✕, not a valve (matches the Dart painter).
|
|
211
264
|
ctx.strokeStyle = theme.graphite;
|
|
212
265
|
ctx.lineWidth = 1.5;
|
|
213
266
|
ctx.lineJoin = "round";
|
|
214
267
|
ctx.beginPath();
|
|
215
268
|
ctx.moveTo(cx - 6, cy - 6);
|
|
216
269
|
ctx.lineTo(cx, cy);
|
|
217
|
-
ctx.lineTo(cx
|
|
270
|
+
ctx.lineTo(cx + 6, cy - 6);
|
|
218
271
|
ctx.closePath();
|
|
219
|
-
ctx.moveTo(cx
|
|
272
|
+
ctx.moveTo(cx - 6, cy + 6);
|
|
220
273
|
ctx.lineTo(cx, cy);
|
|
221
274
|
ctx.lineTo(cx + 6, cy + 6);
|
|
222
275
|
ctx.closePath();
|
|
223
276
|
ctx.stroke();
|
|
224
277
|
}
|
|
278
|
+
function drawSfdPipe(ctx, pipe, scene, theme, highlight, flowPhase) {
|
|
279
|
+
const flow = scene.nodes.find((n) => n.id === pipe.flowId);
|
|
280
|
+
const fromStock = isCloud(pipe.from) ? null : pipe.from;
|
|
281
|
+
const toStock = isCloud(pipe.to) ? null : pipe.to;
|
|
282
|
+
const fromState = materialPipeLegVisualState(pipe.flowId, fromStock, highlight);
|
|
283
|
+
const toState = materialPipeLegVisualState(pipe.flowId, toStock, highlight);
|
|
284
|
+
drawDoubleLine(ctx, pipe.fromPoint, pipe.valvePoint, theme, fromState, highlight, flowPhase);
|
|
285
|
+
const axis = {
|
|
286
|
+
x: pipe.toPoint.x - pipe.valvePoint.x,
|
|
287
|
+
y: pipe.toPoint.y - pipe.valvePoint.y,
|
|
288
|
+
};
|
|
289
|
+
const len = Math.hypot(axis.x, axis.y) || 1;
|
|
290
|
+
const arrowLen = 12;
|
|
291
|
+
const toBase = {
|
|
292
|
+
x: pipe.toPoint.x - (axis.x / len) * arrowLen,
|
|
293
|
+
y: pipe.toPoint.y - (axis.y / len) * arrowLen,
|
|
294
|
+
};
|
|
295
|
+
drawDoubleLine(ctx, pipe.valvePoint, toBase, theme, toState, highlight, flowPhase);
|
|
296
|
+
drawPipeArrow(ctx, pipe.valvePoint, pipe.toPoint, theme, toState, highlight);
|
|
297
|
+
const sw = groupSwatch(flow?.group);
|
|
298
|
+
const tint = sw ? swatchFill(sw, theme.dark) : undefined;
|
|
299
|
+
if (pipe.fromCloud) {
|
|
300
|
+
ctx.save();
|
|
301
|
+
if (fromState === "dimmed")
|
|
302
|
+
ctx.globalAlpha = 0.16;
|
|
303
|
+
drawCloud(ctx, pipe.fromCloud, theme, tint);
|
|
304
|
+
ctx.restore();
|
|
305
|
+
}
|
|
306
|
+
if (pipe.toCloud) {
|
|
307
|
+
ctx.save();
|
|
308
|
+
if (toState === "dimmed")
|
|
309
|
+
ctx.globalAlpha = 0.16;
|
|
310
|
+
drawCloud(ctx, pipe.toCloud, theme, tint);
|
|
311
|
+
ctx.restore();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function drawDoubleLine(ctx, p0, p1, theme, state, highlight, flowPhase) {
|
|
315
|
+
const loopColor = state === "highlighted"
|
|
316
|
+
? (highlight?.type === LoopType.reinforcing ? theme.teal : theme.amber)
|
|
317
|
+
: null;
|
|
318
|
+
ctx.save();
|
|
319
|
+
if (state === "dimmed")
|
|
320
|
+
ctx.globalAlpha = 0.16;
|
|
321
|
+
const dx = p1.x - p0.x;
|
|
322
|
+
const dy = p1.y - p0.y;
|
|
323
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
324
|
+
const off = 3;
|
|
325
|
+
const nx = (-dy / len) * off;
|
|
326
|
+
const ny = (dx / len) * off;
|
|
327
|
+
if (loopColor) {
|
|
328
|
+
ctx.beginPath();
|
|
329
|
+
ctx.moveTo(p0.x, p0.y);
|
|
330
|
+
ctx.lineTo(p1.x, p1.y);
|
|
331
|
+
ctx.strokeStyle = withAlpha(loopColor, 0.22);
|
|
332
|
+
ctx.lineWidth = 10;
|
|
333
|
+
ctx.stroke();
|
|
334
|
+
}
|
|
335
|
+
ctx.strokeStyle = loopColor ?? theme.graphite;
|
|
336
|
+
ctx.lineWidth = loopColor ? 2.4 : 1.5;
|
|
337
|
+
ctx.lineCap = "round";
|
|
338
|
+
if (loopColor) {
|
|
339
|
+
ctx.setLineDash([7, 6]);
|
|
340
|
+
ctx.lineDashOffset = -(flowPhase * 13);
|
|
341
|
+
}
|
|
342
|
+
for (const sign of [-1, 1]) {
|
|
343
|
+
ctx.beginPath();
|
|
344
|
+
ctx.moveTo(p0.x + nx * sign, p0.y + ny * sign);
|
|
345
|
+
ctx.lineTo(p1.x + nx * sign, p1.y + ny * sign);
|
|
346
|
+
ctx.stroke();
|
|
347
|
+
}
|
|
348
|
+
if (loopColor) {
|
|
349
|
+
ctx.setLineDash([]);
|
|
350
|
+
ctx.lineDashOffset = 0;
|
|
351
|
+
}
|
|
352
|
+
ctx.restore();
|
|
353
|
+
}
|
|
354
|
+
function drawPipeArrow(ctx, from, tip, theme, state, highlight) {
|
|
355
|
+
ctx.save();
|
|
356
|
+
if (state === "dimmed")
|
|
357
|
+
ctx.globalAlpha = 0.16;
|
|
358
|
+
const dx = tip.x - from.x;
|
|
359
|
+
const dy = tip.y - from.y;
|
|
360
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
361
|
+
const ux = dx / len;
|
|
362
|
+
const uy = dy / len;
|
|
363
|
+
const px = -uy;
|
|
364
|
+
const py = ux;
|
|
365
|
+
const base = { x: tip.x - ux * 12, y: tip.y - uy * 12 };
|
|
366
|
+
ctx.beginPath();
|
|
367
|
+
ctx.moveTo(tip.x, tip.y);
|
|
368
|
+
ctx.lineTo(base.x + px * 6, base.y + py * 6);
|
|
369
|
+
ctx.lineTo(base.x - px * 6, base.y - py * 6);
|
|
370
|
+
ctx.closePath();
|
|
371
|
+
ctx.fillStyle = state === "highlighted"
|
|
372
|
+
? (highlight?.type === LoopType.reinforcing ? theme.teal : theme.amber)
|
|
373
|
+
: theme.graphite;
|
|
374
|
+
ctx.fill();
|
|
375
|
+
ctx.restore();
|
|
376
|
+
}
|
|
377
|
+
function drawCloud(ctx, c, theme, tint) {
|
|
378
|
+
const puffs = [
|
|
379
|
+
[-14, 2, 9],
|
|
380
|
+
[-5, -6, 11],
|
|
381
|
+
[7, -6, 10],
|
|
382
|
+
[15, 2, 9],
|
|
383
|
+
[0, 4, 11],
|
|
384
|
+
];
|
|
385
|
+
ctx.fillStyle = tint ?? theme.line2;
|
|
386
|
+
for (const [dx, dy, r] of puffs) {
|
|
387
|
+
circle(ctx, { x: c.x + dx, y: c.y + dy }, r);
|
|
388
|
+
ctx.fill();
|
|
389
|
+
}
|
|
390
|
+
ctx.strokeStyle = theme.line2;
|
|
391
|
+
ctx.lineWidth = 1.45;
|
|
392
|
+
for (const [dx, dy, r] of puffs) {
|
|
393
|
+
circle(ctx, { x: c.x + dx, y: c.y + dy }, r);
|
|
394
|
+
ctx.stroke();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function drawSfdValve(ctx, box, node, pipe, theme, selected, live, pulse, dim) {
|
|
398
|
+
ctx.save();
|
|
399
|
+
if (dim)
|
|
400
|
+
ctx.globalAlpha = 0.16;
|
|
401
|
+
const c = { x: box.cx, y: box.cy };
|
|
402
|
+
if (selected) {
|
|
403
|
+
const grow = 10 + 20 * pulse;
|
|
404
|
+
circle(ctx, c, grow);
|
|
405
|
+
ctx.fillStyle = withAlpha(theme.teal, 0.18 * (1 - pulse));
|
|
406
|
+
ctx.fill();
|
|
407
|
+
circle(ctx, c, 25);
|
|
408
|
+
ctx.lineWidth = 1;
|
|
409
|
+
ctx.strokeStyle = withAlpha(theme.teal, 0.55);
|
|
410
|
+
ctx.stroke();
|
|
411
|
+
}
|
|
412
|
+
if (live) {
|
|
413
|
+
circle(ctx, c, 27);
|
|
414
|
+
ctx.fillStyle = withAlpha(theme.live, 0.12);
|
|
415
|
+
ctx.fill();
|
|
416
|
+
circle(ctx, c, 22);
|
|
417
|
+
ctx.lineWidth = 2;
|
|
418
|
+
ctx.strokeStyle = theme.live;
|
|
419
|
+
ctx.stroke();
|
|
420
|
+
}
|
|
421
|
+
const angle = pipe?.axisAngle ?? 0;
|
|
422
|
+
const ux = Math.cos(angle);
|
|
423
|
+
const uy = Math.sin(angle);
|
|
424
|
+
const px = -uy;
|
|
425
|
+
const py = ux;
|
|
426
|
+
const size = 9;
|
|
427
|
+
const drawWing = (sign) => {
|
|
428
|
+
const bx = c.x + px * size * sign;
|
|
429
|
+
const by = c.y + py * size * sign;
|
|
430
|
+
ctx.moveTo(bx + ux * size, by + uy * size);
|
|
431
|
+
ctx.lineTo(c.x, c.y);
|
|
432
|
+
ctx.lineTo(bx - ux * size, by - uy * size);
|
|
433
|
+
ctx.closePath();
|
|
434
|
+
};
|
|
435
|
+
ctx.beginPath();
|
|
436
|
+
drawWing(-1);
|
|
437
|
+
drawWing(1);
|
|
438
|
+
ctx.fillStyle = theme.surface;
|
|
439
|
+
ctx.fill();
|
|
440
|
+
const sw = groupSwatch(node.group);
|
|
441
|
+
ctx.strokeStyle = sw ? swatchBorder(sw, theme.dark) : theme.ink;
|
|
442
|
+
ctx.lineWidth = 1.5;
|
|
443
|
+
ctx.lineJoin = "round";
|
|
444
|
+
ctx.stroke();
|
|
445
|
+
ctx.fillStyle = theme.ink;
|
|
446
|
+
ctx.font = "500 12px " + LABEL_FONT;
|
|
447
|
+
setLetterSpacing(ctx, LABEL_TRACKING);
|
|
448
|
+
ctx.textAlign = "center";
|
|
449
|
+
ctx.textBaseline = "top";
|
|
450
|
+
ctx.fillText(fitText(ctx, node.label, Math.max(72, box.w)), c.x, c.y + 16);
|
|
451
|
+
ctx.restore();
|
|
452
|
+
}
|
|
225
453
|
function drawEdge(ctx, g, theme, selected, hl, flowPhase) {
|
|
226
454
|
const inLoop = hl ? hl.edgeIds.has(g.id) : false;
|
|
227
455
|
const dim = hl ? !inLoop : false;
|
|
@@ -289,8 +517,8 @@ function tracePath(ctx, pts) {
|
|
|
289
517
|
ctx.lineTo(pts[i].x, pts[i].y);
|
|
290
518
|
}
|
|
291
519
|
function drawArrow(ctx, tip, angle, color) {
|
|
292
|
-
const size =
|
|
293
|
-
const perp = size * 0.
|
|
520
|
+
const size = 12;
|
|
521
|
+
const perp = size * 0.5;
|
|
294
522
|
const bx = tip.x - Math.cos(angle) * size;
|
|
295
523
|
const by = tip.y - Math.sin(angle) * size;
|
|
296
524
|
const nx = -Math.sin(angle);
|
|
@@ -322,7 +550,7 @@ function drawPolarityChip(ctx, c, polarity, theme, selected) {
|
|
|
322
550
|
ctx.textAlign = "center";
|
|
323
551
|
ctx.textBaseline = "middle";
|
|
324
552
|
// En-dash (U+2013) for the negative glyph, matching the app.
|
|
325
|
-
ctx.fillText(polarity === "-" ? "–" :
|
|
553
|
+
ctx.fillText(polarity === "-" ? "–" : polarity, c.x, c.y + 0.5);
|
|
326
554
|
}
|
|
327
555
|
function drawDelay(ctx, c, angle, theme) {
|
|
328
556
|
ctx.save();
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* handed the graph + interaction caches to build from.
|
|
15
15
|
*/
|
|
16
16
|
import { Camera, Point } from "./camera";
|
|
17
|
+
import { DiagramViewMode } from "./geometry";
|
|
17
18
|
import { GraphView } from "../engine/engine";
|
|
18
19
|
import { Scene } from "./painter";
|
|
19
20
|
/** Measure a label's pixel width at the canvas label font. */
|
|
@@ -43,7 +44,7 @@ export declare class SceneCache {
|
|
|
43
44
|
* reference when no geometric input moved; otherwise recomputes boxes, edges,
|
|
44
45
|
* and badges. Returns `null` for a null graph.
|
|
45
46
|
*/
|
|
46
|
-
build(graph: GraphView | null, bowSigns: Map<string, number>, badgeOverrides: Map<string, Point
|
|
47
|
+
build(graph: GraphView | null, bowSigns: Map<string, number>, badgeOverrides: Map<string, Point>, mode?: DiagramViewMode): Scene | null;
|
|
47
48
|
/**
|
|
48
49
|
* Fit `camera` to the current scene's node bounds. No-op (returns `false`)
|
|
49
50
|
* when there's no scene, no nodes, or a zero-sized viewport — the caller keeps
|