@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.
@@ -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
- m.set(n.id, { id: n.id, cx: n.x, cy: n.y, w, h, type: n.type });
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 both use the same bow sign; their opposite chord
86
- // directions make the two arcs split to opposite geometric sides.
87
- bow = mag;
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
+ }
@@ -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;
@@ -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
- for (const g of scene.edges) {
40
- drawEdge(ctx, g, theme, g.id === ui.selectedEdgeId, hl, ui.flowPhase);
41
- }
42
- for (const n of scene.nodes) {
43
- const box = scene.boxes.get(n.id);
44
- if (box) {
45
- 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);
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 (a bowtie ▷◁).
209
- // Each sub-path must be closed so its outer base edge is drawn — otherwise
210
- // the four spokes read as an ✕, not a valve (matches the Dart painter).
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 - 6, cy + 6);
270
+ ctx.lineTo(cx + 6, cy - 6);
218
271
  ctx.closePath();
219
- ctx.moveTo(cx + 6, cy - 6);
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 = 8;
293
- const perp = size * 0.55;
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 === "-" ? "–" : "+", c.x, c.y + 0.5);
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>): Scene | null;
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