@diagrammo/dgmo 0.6.0 → 0.6.2
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/.claude/commands/dgmo.md +76 -0
- package/dist/cli.cjs +164 -162
- package/dist/index.cjs +1146 -647
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -21
- package/dist/index.d.ts +9 -21
- package/dist/index.js +1146 -647
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +33 -50
- package/package.json +4 -3
- package/src/c4/layout.ts +75 -72
- package/src/c4/renderer.ts +122 -119
- package/src/cli.ts +130 -40
- package/src/d3.ts +55 -35
- package/src/echarts.ts +24 -24
- package/src/er/classify.ts +206 -0
- package/src/er/layout.ts +259 -94
- package/src/er/renderer.ts +246 -26
- package/src/index.ts +2 -2
- package/src/infra/compute.ts +1 -21
- package/src/infra/layout.ts +60 -13
- package/src/infra/parser.ts +5 -32
- package/src/infra/renderer.ts +403 -196
- package/src/infra/types.ts +1 -11
- package/src/initiative-status/layout.ts +46 -27
- package/src/kanban/renderer.ts +28 -24
- package/src/org/renderer.ts +24 -23
- package/src/render.ts +2 -2
- package/src/sequence/renderer.ts +24 -19
- package/src/sitemap/layout.ts +7 -14
- package/src/sitemap/renderer.ts +30 -29
- package/src/utils/legend-constants.ts +25 -0
- package/.claude/skills/dgmo-chart/SKILL.md +0 -141
- package/.claude/skills/dgmo-flowchart/SKILL.md +0 -61
- package/.claude/skills/dgmo-generate/SKILL.md +0 -59
- package/.claude/skills/dgmo-sequence/SKILL.md +0 -104
package/src/infra/renderer.ts
CHANGED
|
@@ -16,6 +16,19 @@ import type { InfraRole } from './roles';
|
|
|
16
16
|
import { parseInfra } from './parser';
|
|
17
17
|
import { computeInfra } from './compute';
|
|
18
18
|
import { layoutInfra } from './layout';
|
|
19
|
+
import {
|
|
20
|
+
LEGEND_HEIGHT,
|
|
21
|
+
LEGEND_PILL_PAD,
|
|
22
|
+
LEGEND_PILL_FONT_SIZE,
|
|
23
|
+
LEGEND_PILL_FONT_W,
|
|
24
|
+
LEGEND_CAPSULE_PAD,
|
|
25
|
+
LEGEND_DOT_R,
|
|
26
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
27
|
+
LEGEND_ENTRY_FONT_W,
|
|
28
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
29
|
+
LEGEND_ENTRY_TRAIL,
|
|
30
|
+
LEGEND_GROUP_GAP,
|
|
31
|
+
} from '../utils/legend-constants';
|
|
19
32
|
|
|
20
33
|
// ============================================================
|
|
21
34
|
// Constants
|
|
@@ -39,22 +52,7 @@ const NODE_PAD_BOTTOM = 10;
|
|
|
39
52
|
const COLLAPSE_BAR_HEIGHT = 6;
|
|
40
53
|
const COLLAPSE_BAR_INSET = 0;
|
|
41
54
|
|
|
42
|
-
//
|
|
43
|
-
const LEGEND_HEIGHT = 28;
|
|
44
|
-
const LEGEND_PILL_PAD = 16;
|
|
45
|
-
const LEGEND_PILL_FONT_SIZE = 11;
|
|
46
|
-
const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
|
|
47
|
-
const LEGEND_CAPSULE_PAD = 4;
|
|
48
|
-
const LEGEND_DOT_R = 4;
|
|
49
|
-
const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
50
|
-
const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
|
|
51
|
-
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
52
|
-
const LEGEND_ENTRY_TRAIL = 8;
|
|
53
|
-
const LEGEND_GROUP_GAP = 12;
|
|
54
|
-
const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram
|
|
55
|
-
const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
|
|
56
|
-
const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
|
|
57
|
-
const SPEED_BADGE_GAP = 6; // gap between speed option slots
|
|
55
|
+
const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
|
|
58
56
|
|
|
59
57
|
// Health colors (from UX spec)
|
|
60
58
|
const COLOR_HEALTHY = '#22c55e';
|
|
@@ -131,10 +129,352 @@ const REJECT_COUNT_MAX = 3;
|
|
|
131
129
|
// Edge path generator
|
|
132
130
|
// ============================================================
|
|
133
131
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
type Pt = { x: number; y: number };
|
|
133
|
+
|
|
134
|
+
/** Produce an SVG path string for a sequence of waypoints.
|
|
135
|
+
* 2-point paths use curveBumpX/Y (nice S-curve).
|
|
136
|
+
* Multi-point obstacle-avoiding paths use CatmullRom for a smooth fit. */
|
|
137
|
+
function buildPathD(pts: Pt[], direction: 'LR' | 'TB'): string {
|
|
138
|
+
const gen = d3Shape.line<Pt>().x((d) => d.x).y((d) => d.y);
|
|
139
|
+
if (pts.length <= 2) {
|
|
140
|
+
gen.curve(direction === 'TB' ? d3Shape.curveBumpY : d3Shape.curveBumpX);
|
|
141
|
+
} else {
|
|
142
|
+
gen.curve(d3Shape.curveCatmullRom.alpha(0.5));
|
|
143
|
+
}
|
|
144
|
+
return gen(pts) ?? '';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type Rect = { x: number; y: number; width: number; height: number };
|
|
148
|
+
|
|
149
|
+
/** Port-order exit and enter points for edges to eliminate fan-out crossings.
|
|
150
|
+
*
|
|
151
|
+
* For each node with ≥2 outgoing edges: sort edges by target perpendicular coord
|
|
152
|
+
* and assign source-border exit positions in the same order.
|
|
153
|
+
* For each node with ≥2 incoming edges: sort edges by source perpendicular coord
|
|
154
|
+
* and assign target-border enter positions in the same order.
|
|
155
|
+
*
|
|
156
|
+
* Returns two maps keyed by "sourceId:targetId":
|
|
157
|
+
* - srcPts: port-ordered exit point on the source border
|
|
158
|
+
* - tgtPts: port-ordered enter point on the target border */
|
|
159
|
+
function computePortPts(
|
|
160
|
+
edges: InfraLayoutEdge[],
|
|
161
|
+
nodeMap: Map<string, InfraLayoutNode>,
|
|
162
|
+
direction: 'LR' | 'TB',
|
|
163
|
+
): { srcPts: Map<string, Pt>; tgtPts: Map<string, Pt> } {
|
|
164
|
+
const srcPts = new Map<string, Pt>();
|
|
165
|
+
const tgtPts = new Map<string, Pt>();
|
|
166
|
+
const PAD = 0.1; // keep ports 10% in from node edges
|
|
167
|
+
|
|
168
|
+
const activeEdges = edges.filter((e) => e.points.length > 0);
|
|
169
|
+
|
|
170
|
+
// ── Source (exit) port ordering ──────────────────────────────────────────
|
|
171
|
+
const bySource = new Map<string, InfraLayoutEdge[]>();
|
|
172
|
+
for (const e of activeEdges) {
|
|
173
|
+
if (!bySource.has(e.sourceId)) bySource.set(e.sourceId, []);
|
|
174
|
+
bySource.get(e.sourceId)!.push(e);
|
|
175
|
+
}
|
|
176
|
+
for (const [sourceId, es] of bySource) {
|
|
177
|
+
if (es.length < 2) continue;
|
|
178
|
+
const source = nodeMap.get(sourceId);
|
|
179
|
+
if (!source) continue;
|
|
180
|
+
const sorted = es
|
|
181
|
+
.map((e) => ({ e, t: nodeMap.get(e.targetId) }))
|
|
182
|
+
.filter((x): x is { e: InfraLayoutEdge; t: InfraLayoutNode } => x.t != null)
|
|
183
|
+
.sort((a, b) => (direction === 'LR' ? a.t.y - b.t.y : a.t.x - b.t.x));
|
|
184
|
+
const n = sorted.length;
|
|
185
|
+
for (let i = 0; i < n; i++) {
|
|
186
|
+
const frac = n === 1 ? 0.5 : PAD + (1 - 2 * PAD) * i / (n - 1);
|
|
187
|
+
const { e, t } = sorted[i];
|
|
188
|
+
const isBackward = direction === 'LR' ? t.x < source.x : t.y < source.y;
|
|
189
|
+
if (direction === 'LR') {
|
|
190
|
+
srcPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
191
|
+
x: isBackward ? source.x - source.width / 2 : source.x + source.width / 2,
|
|
192
|
+
y: source.y - source.height / 2 + frac * source.height,
|
|
193
|
+
});
|
|
194
|
+
} else {
|
|
195
|
+
srcPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
196
|
+
x: source.x - source.width / 2 + frac * source.width,
|
|
197
|
+
y: isBackward ? source.y - source.height / 2 : source.y + source.height / 2,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Target (enter) port ordering ─────────────────────────────────────────
|
|
204
|
+
const byTarget = new Map<string, InfraLayoutEdge[]>();
|
|
205
|
+
for (const e of activeEdges) {
|
|
206
|
+
if (!byTarget.has(e.targetId)) byTarget.set(e.targetId, []);
|
|
207
|
+
byTarget.get(e.targetId)!.push(e);
|
|
208
|
+
}
|
|
209
|
+
for (const [targetId, es] of byTarget) {
|
|
210
|
+
if (es.length < 2) continue;
|
|
211
|
+
const target = nodeMap.get(targetId);
|
|
212
|
+
if (!target) continue;
|
|
213
|
+
const sorted = es
|
|
214
|
+
.map((e) => ({ e, s: nodeMap.get(e.sourceId) }))
|
|
215
|
+
.filter((x): x is { e: InfraLayoutEdge; s: InfraLayoutNode } => x.s != null)
|
|
216
|
+
.sort((a, b) => (direction === 'LR' ? a.s.y - b.s.y : a.s.x - b.s.x));
|
|
217
|
+
const n = sorted.length;
|
|
218
|
+
for (let i = 0; i < n; i++) {
|
|
219
|
+
const frac = n === 1 ? 0.5 : PAD + (1 - 2 * PAD) * i / (n - 1);
|
|
220
|
+
const { e, s } = sorted[i];
|
|
221
|
+
const isBackward = direction === 'LR' ? target.x < s.x : target.y < s.y;
|
|
222
|
+
if (direction === 'LR') {
|
|
223
|
+
tgtPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
224
|
+
x: isBackward ? target.x + target.width / 2 : target.x - target.width / 2,
|
|
225
|
+
y: target.y - target.height / 2 + frac * target.height,
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
tgtPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
229
|
+
x: target.x - target.width / 2 + frac * target.width,
|
|
230
|
+
y: isBackward ? target.y + target.height / 2 : target.y - target.height / 2,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { srcPts, tgtPts };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Find the Y lane for routing around blocking obstacles.
|
|
240
|
+
* Prefers threading through Y gaps between blocking rects over routing above/below all.
|
|
241
|
+
* Picks the candidate closest to targetY to minimise arc size.
|
|
242
|
+
*
|
|
243
|
+
* Uses a tight merge expansion (MERGE_SLOP) so narrow inter-group gaps are preserved
|
|
244
|
+
* and can be threaded, rather than being swallowed into one large merged interval. */
|
|
245
|
+
function findRoutingLane(
|
|
246
|
+
blocking: Rect[],
|
|
247
|
+
targetY: number,
|
|
248
|
+
margin: number,
|
|
249
|
+
): number {
|
|
250
|
+
// Use a small slop for merging so closely-spaced (but distinct) groups
|
|
251
|
+
// stay as separate intervals and the gap between them can be threaded.
|
|
252
|
+
const MERGE_SLOP = 4;
|
|
253
|
+
const sorted = [...blocking].sort((a, b) => (a.y + a.height / 2) - (b.y + b.height / 2));
|
|
254
|
+
const merged: [number, number][] = [];
|
|
255
|
+
for (const r of sorted) {
|
|
256
|
+
const lo = r.y - MERGE_SLOP;
|
|
257
|
+
const hi = r.y + r.height + MERGE_SLOP;
|
|
258
|
+
if (merged.length && lo <= merged[merged.length - 1][1]) {
|
|
259
|
+
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], hi);
|
|
260
|
+
} else {
|
|
261
|
+
merged.push([lo, hi]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (merged.length === 0) return targetY;
|
|
265
|
+
|
|
266
|
+
// Candidate lanes: above all, below all, mid-points of gaps wide enough to thread.
|
|
267
|
+
// MIN_GAP: allow narrow gaps (edge is ~1.5px, so even 10px clearance is fine).
|
|
268
|
+
const MIN_GAP = 10;
|
|
269
|
+
const candidates: number[] = [
|
|
270
|
+
merged[0][0] - margin, // above all blocking rects
|
|
271
|
+
merged[merged.length - 1][1] + margin, // below all blocking rects
|
|
272
|
+
];
|
|
273
|
+
for (let i = 0; i < merged.length - 1; i++) {
|
|
274
|
+
const gapLo = merged[i][1];
|
|
275
|
+
const gapHi = merged[i + 1][0];
|
|
276
|
+
if (gapHi - gapLo >= MIN_GAP) {
|
|
277
|
+
candidates.push((gapLo + gapHi) / 2); // thread through the gap
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Return the candidate closest to targetY (tightest arc)
|
|
282
|
+
return candidates.reduce((best, c) =>
|
|
283
|
+
Math.abs(c - targetY) < Math.abs(best - targetY) ? c : best,
|
|
284
|
+
candidates[0]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Check whether segment p1→p2 passes through (or has an endpoint inside) the rectangle. */
|
|
288
|
+
function segmentIntersectsRect(p1: Pt, p2: Pt, rect: { x: number; y: number; width: number; height: number }): boolean {
|
|
289
|
+
const { x: rx, y: ry, width: rw, height: rh } = rect;
|
|
290
|
+
const rr = rx + rw;
|
|
291
|
+
const rb = ry + rh;
|
|
292
|
+
// Point-in-rect check
|
|
293
|
+
const inRect = (p: Pt) => p.x >= rx && p.x <= rr && p.y >= ry && p.y <= rb;
|
|
294
|
+
if (inRect(p1) || inRect(p2)) return true;
|
|
295
|
+
// Segment bounding box vs rect
|
|
296
|
+
if (Math.max(p1.x, p2.x) < rx || Math.min(p1.x, p2.x) > rr) return false;
|
|
297
|
+
if (Math.max(p1.y, p2.y) < ry || Math.min(p1.y, p2.y) > rb) return false;
|
|
298
|
+
// Cross product sign helper (z-component of cross product)
|
|
299
|
+
const cross = (o: Pt, a: Pt, b: Pt) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
300
|
+
// Does segment p1p2 cross segment a→b?
|
|
301
|
+
const crosses = (a: Pt, b: Pt) => {
|
|
302
|
+
const d1 = cross(a, b, p1);
|
|
303
|
+
const d2 = cross(a, b, p2);
|
|
304
|
+
const d3 = cross(p1, p2, a);
|
|
305
|
+
const d4 = cross(p1, p2, b);
|
|
306
|
+
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
|
307
|
+
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
|
|
308
|
+
};
|
|
309
|
+
const tl: Pt = { x: rx, y: ry };
|
|
310
|
+
const tr: Pt = { x: rr, y: ry };
|
|
311
|
+
const br: Pt = { x: rr, y: rb };
|
|
312
|
+
const bl: Pt = { x: rx, y: rb };
|
|
313
|
+
return crosses(tl, tr) || crosses(tr, br) || crosses(br, bl) || crosses(bl, tl);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Check whether the curveBumpX/Y S-curve from sc to tc intersects rect.
|
|
317
|
+
*
|
|
318
|
+
* curveBumpX (LR) stays near sc.y for the left half of the edge, transitions
|
|
319
|
+
* vertically at the midpoint, then stays near tc.y for the right half.
|
|
320
|
+
* Approximated as three Manhattan segments:
|
|
321
|
+
* sc → (midX, sc.y) → (midX, tc.y) → tc
|
|
322
|
+
*
|
|
323
|
+
* curveBumpY (TB) mirrors this on the other axis. */
|
|
324
|
+
function curveIntersectsRect(sc: Pt, tc: Pt, rect: Rect, direction: 'LR' | 'TB'): boolean {
|
|
325
|
+
if (direction === 'LR') {
|
|
326
|
+
const midX = (sc.x + tc.x) / 2;
|
|
327
|
+
const m1: Pt = { x: midX, y: sc.y };
|
|
328
|
+
const m2: Pt = { x: midX, y: tc.y };
|
|
329
|
+
return segmentIntersectsRect(sc, m1, rect)
|
|
330
|
+
|| segmentIntersectsRect(m1, m2, rect)
|
|
331
|
+
|| segmentIntersectsRect(m2, tc, rect);
|
|
332
|
+
} else {
|
|
333
|
+
const midY = (sc.y + tc.y) / 2;
|
|
334
|
+
const m1: Pt = { x: sc.x, y: midY };
|
|
335
|
+
const m2: Pt = { x: tc.x, y: midY };
|
|
336
|
+
return segmentIntersectsRect(sc, m1, rect)
|
|
337
|
+
|| segmentIntersectsRect(m1, m2, rect)
|
|
338
|
+
|| segmentIntersectsRect(m2, tc, rect);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Compute waypoints for an edge.
|
|
343
|
+
*
|
|
344
|
+
* - Backward edges (going against the primary layout direction) arc above/left
|
|
345
|
+
* of all layout content to avoid crossing everything.
|
|
346
|
+
* - Forward edges that cross a group bounding box or an unrelated node arc
|
|
347
|
+
* above or below the collective blocking region (gap-aware Y routing).
|
|
348
|
+
* - Clear forward edges use a direct 2-point path (curveBumpX/Y S-curve). */
|
|
349
|
+
function edgeWaypoints(
|
|
350
|
+
source: InfraLayoutNode,
|
|
351
|
+
target: InfraLayoutNode,
|
|
352
|
+
groups: InfraLayoutGroup[],
|
|
353
|
+
nodes: InfraLayoutNode[],
|
|
354
|
+
direction: 'LR' | 'TB',
|
|
355
|
+
margin = 30,
|
|
356
|
+
srcExitPt?: Pt, // port-ordered exit point on source border
|
|
357
|
+
tgtEnterPt?: Pt, // port-ordered enter point on target border
|
|
358
|
+
): Pt[] {
|
|
359
|
+
const sc: Pt = { x: source.x, y: source.y };
|
|
360
|
+
const tc: Pt = { x: target.x, y: target.y };
|
|
361
|
+
|
|
362
|
+
// ── Backward edge handling ───────────────────────────────────────────────
|
|
363
|
+
// curveBumpX/Y on a backward edge creates a loop. Route around obstacles
|
|
364
|
+
// in the backward path's axis band for a tight arc (not global min/max).
|
|
365
|
+
const isBackward = direction === 'LR' ? tc.x < sc.x : tc.y < sc.y;
|
|
366
|
+
if (isBackward) {
|
|
367
|
+
if (direction === 'LR') {
|
|
368
|
+
// Collect group/node rects that overlap the X band [tc.x, sc.x]
|
|
369
|
+
const xBandObs: Rect[] = [];
|
|
370
|
+
for (const g of groups) {
|
|
371
|
+
if (g.x + g.width < tc.x - margin || g.x > sc.x + margin) continue;
|
|
372
|
+
xBandObs.push({ x: g.x, y: g.y, width: g.width, height: g.height });
|
|
373
|
+
}
|
|
374
|
+
for (const n of nodes) {
|
|
375
|
+
if (n.id === source.id || n.id === target.id) continue;
|
|
376
|
+
const nLeft = n.x - n.width / 2;
|
|
377
|
+
const nRight = n.x + n.width / 2;
|
|
378
|
+
if (nRight < tc.x - margin || nLeft > sc.x + margin) continue;
|
|
379
|
+
xBandObs.push({ x: nLeft, y: n.y - n.height / 2, width: n.width, height: n.height });
|
|
380
|
+
}
|
|
381
|
+
const midY = (sc.y + tc.y) / 2;
|
|
382
|
+
const routeY = xBandObs.length > 0 ? findRoutingLane(xBandObs, midY, margin) : midY;
|
|
383
|
+
const exitBorder: Pt = srcExitPt ?? nodeBorderPoint(source, { x: sc.x, y: routeY });
|
|
384
|
+
const exitPt : Pt = { x: exitBorder.x, y: routeY };
|
|
385
|
+
const enterPt : Pt = { x: tc.x, y: routeY };
|
|
386
|
+
const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
|
|
387
|
+
return srcExitPt
|
|
388
|
+
? [srcExitPt, exitPt, enterPt, tp]
|
|
389
|
+
: [exitBorder, exitPt, enterPt, tp];
|
|
390
|
+
} else {
|
|
391
|
+
// TB backward: collect obstacles in Y band [tc.y, sc.y]; find X lane
|
|
392
|
+
const yBandObs: Rect[] = [];
|
|
393
|
+
for (const g of groups) {
|
|
394
|
+
if (g.y + g.height < tc.y - margin || g.y > sc.y + margin) continue;
|
|
395
|
+
yBandObs.push({ x: g.x, y: g.y, width: g.width, height: g.height });
|
|
396
|
+
}
|
|
397
|
+
for (const n of nodes) {
|
|
398
|
+
if (n.id === source.id || n.id === target.id) continue;
|
|
399
|
+
const nTop = n.y - n.height / 2;
|
|
400
|
+
const nBot = n.y + n.height / 2;
|
|
401
|
+
if (nBot < tc.y - margin || nTop > sc.y + margin) continue;
|
|
402
|
+
yBandObs.push({ x: n.x - n.width / 2, y: nTop, width: n.width, height: n.height });
|
|
403
|
+
}
|
|
404
|
+
// Rotate axes so findRoutingLane (which works in Y) resolves an X lane
|
|
405
|
+
const rotated = yBandObs.map((r) => ({ x: r.y, y: r.x, width: r.height, height: r.width }));
|
|
406
|
+
const midX = (sc.x + tc.x) / 2;
|
|
407
|
+
const routeX = rotated.length > 0 ? findRoutingLane(rotated, midX, margin) : midX;
|
|
408
|
+
const exitPt : Pt = srcExitPt ?? { x: routeX, y: sc.y };
|
|
409
|
+
const enterPt : Pt = { x: routeX, y: tc.y };
|
|
410
|
+
return [
|
|
411
|
+
srcExitPt ?? nodeBorderPoint(source, exitPt),
|
|
412
|
+
exitPt,
|
|
413
|
+
enterPt,
|
|
414
|
+
tgtEnterPt ?? nodeBorderPoint(target, enterPt),
|
|
415
|
+
];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Forward edge: obstacle avoidance (groups + individual nodes) ─────────
|
|
420
|
+
const blocking: { x: number; y: number; width: number; height: number }[] = [];
|
|
421
|
+
const blockingGroupIds = new Set<string>();
|
|
422
|
+
|
|
423
|
+
// Use actual path endpoints (port-ordered) for more accurate blocking detection.
|
|
424
|
+
// Node centers underestimate midX/midY, causing nearby obstacles to go undetected.
|
|
425
|
+
const pathSrc: Pt = srcExitPt ?? sc;
|
|
426
|
+
const pathTgt: Pt = tgtEnterPt ?? tc;
|
|
427
|
+
|
|
428
|
+
// Groups (excluding source/target groups)
|
|
429
|
+
for (const g of groups) {
|
|
430
|
+
if (g.id === source.groupId || g.id === target.groupId) continue;
|
|
431
|
+
const gRect: Rect = { x: g.x, y: g.y, width: g.width, height: g.height };
|
|
432
|
+
if (curveIntersectsRect(pathSrc, pathTgt, gRect, direction)) {
|
|
433
|
+
blocking.push(gRect);
|
|
434
|
+
blockingGroupIds.add(g.id);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Individual nodes not already covered by a blocking group rect
|
|
439
|
+
for (const n of nodes) {
|
|
440
|
+
if (n.id === source.id || n.id === target.id) continue;
|
|
441
|
+
// Skip nodes in source/target groups (routing around the group handles them)
|
|
442
|
+
if (n.groupId && (n.groupId === source.groupId || n.groupId === target.groupId)) continue;
|
|
443
|
+
// Skip nodes inside a group whose bounding box is already blocking
|
|
444
|
+
if (n.groupId && blockingGroupIds.has(n.groupId)) continue;
|
|
445
|
+
const nodeRect: Rect = { x: n.x - n.width / 2, y: n.y - n.height / 2, width: n.width, height: n.height };
|
|
446
|
+
if (curveIntersectsRect(pathSrc, pathTgt, nodeRect, direction)) {
|
|
447
|
+
blocking.push(nodeRect);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (blocking.length === 0) {
|
|
452
|
+
// Direct 2-point path — use port-ordered entry/exit points when provided.
|
|
453
|
+
const sp = srcExitPt ?? nodeBorderPoint(source, tc);
|
|
454
|
+
const tp = tgtEnterPt ?? nodeBorderPoint(target, sp);
|
|
455
|
+
return [sp, tp];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const obsLeft = Math.min(...blocking.map((o) => o.x));
|
|
459
|
+
const obsRight = Math.max(...blocking.map((o) => o.x + o.width));
|
|
460
|
+
|
|
461
|
+
const routeY = findRoutingLane(blocking, tc.y, margin);
|
|
462
|
+
|
|
463
|
+
// Clamp exit/enter X to [sc.x, tc.x] for LR so the path never reverses
|
|
464
|
+
// direction when an obstacle's bounding box extends past source or target.
|
|
465
|
+
const exitX = direction === 'LR' ? Math.max(sc.x, obsLeft - margin) : obsLeft - margin;
|
|
466
|
+
const enterX = direction === 'LR' ? Math.min(tc.x, obsRight + margin) : obsRight + margin;
|
|
467
|
+
const exitPt : Pt = { x: exitX, y: routeY };
|
|
468
|
+
const enterPt : Pt = { x: enterX, y: routeY };
|
|
469
|
+
|
|
470
|
+
const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
|
|
471
|
+
|
|
472
|
+
if (srcExitPt) {
|
|
473
|
+
return [srcExitPt, exitPt, enterPt, tp];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return [nodeBorderPoint(source, exitPt), exitPt, enterPt, tp];
|
|
477
|
+
}
|
|
138
478
|
|
|
139
479
|
/** Compute the point on a node's border closest to an external target point. */
|
|
140
480
|
function nodeBorderPoint(
|
|
@@ -599,12 +939,15 @@ function renderEdgePaths(
|
|
|
599
939
|
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
600
940
|
edges: InfraLayoutEdge[],
|
|
601
941
|
nodes: InfraLayoutNode[],
|
|
942
|
+
groups: InfraLayoutGroup[],
|
|
602
943
|
palette: PaletteColors,
|
|
603
944
|
isDark: boolean,
|
|
604
945
|
animate: boolean,
|
|
946
|
+
direction: 'LR' | 'TB',
|
|
605
947
|
) {
|
|
606
948
|
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
607
949
|
const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
|
|
950
|
+
const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
|
|
608
951
|
|
|
609
952
|
for (const edge of edges) {
|
|
610
953
|
if (edge.points.length === 0) continue;
|
|
@@ -614,29 +957,13 @@ function renderEdgePaths(
|
|
|
614
957
|
const color = edgeColor(edge, palette);
|
|
615
958
|
const strokeW = edgeWidth();
|
|
616
959
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
pts = [...pts].reverse();
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Prepend source border point and append target border point so edges
|
|
629
|
-
// visually connect to node boundaries (dagre waypoints float between nodes)
|
|
630
|
-
if (sourceNode && pts.length > 0) {
|
|
631
|
-
const bp = nodeBorderPoint(sourceNode, pts[0]);
|
|
632
|
-
pts = [bp, ...pts];
|
|
633
|
-
}
|
|
634
|
-
if (targetNode && pts.length > 0) {
|
|
635
|
-
const bp = nodeBorderPoint(targetNode, pts[pts.length - 1]);
|
|
636
|
-
pts = [...pts, bp];
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
const pathD = lineGenerator(pts) ?? '';
|
|
960
|
+
if (!sourceNode || !targetNode) continue;
|
|
961
|
+
const key = `${edge.sourceId}:${edge.targetId}`;
|
|
962
|
+
const pts = edgeWaypoints(
|
|
963
|
+
sourceNode, targetNode, groups, nodes, direction, 30,
|
|
964
|
+
srcPts.get(key), tgtPts.get(key),
|
|
965
|
+
);
|
|
966
|
+
const pathD = buildPathD(pts, direction);
|
|
640
967
|
const edgeG = svg.append('g')
|
|
641
968
|
.attr('class', 'infra-edge')
|
|
642
969
|
.attr('data-line-number', edge.lineNumber);
|
|
@@ -676,16 +1003,30 @@ function renderEdgePaths(
|
|
|
676
1003
|
function renderEdgeLabels(
|
|
677
1004
|
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
678
1005
|
edges: InfraLayoutEdge[],
|
|
1006
|
+
nodes: InfraLayoutNode[],
|
|
1007
|
+
groups: InfraLayoutGroup[],
|
|
679
1008
|
palette: PaletteColors,
|
|
680
1009
|
isDark: boolean,
|
|
681
1010
|
animate: boolean,
|
|
1011
|
+
direction: 'LR' | 'TB',
|
|
682
1012
|
) {
|
|
1013
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
1014
|
+
const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
|
|
683
1015
|
for (const edge of edges) {
|
|
684
1016
|
if (edge.points.length === 0) continue;
|
|
685
1017
|
if (!edge.label) continue;
|
|
686
1018
|
|
|
687
|
-
const
|
|
688
|
-
const
|
|
1019
|
+
const sourceNode = nodeMap.get(edge.sourceId);
|
|
1020
|
+
const targetNode = nodeMap.get(edge.targetId);
|
|
1021
|
+
if (!sourceNode || !targetNode) continue;
|
|
1022
|
+
|
|
1023
|
+
const key = `${edge.sourceId}:${edge.targetId}`;
|
|
1024
|
+
const wps = edgeWaypoints(
|
|
1025
|
+
sourceNode, targetNode, groups, nodes, direction, 30,
|
|
1026
|
+
srcPts.get(key), tgtPts.get(key),
|
|
1027
|
+
);
|
|
1028
|
+
// Label midpoint: middle waypoint of the routed path
|
|
1029
|
+
const midPt = wps[Math.floor(wps.length / 2)];
|
|
689
1030
|
const labelText = edge.label;
|
|
690
1031
|
|
|
691
1032
|
const g = svg.append('g')
|
|
@@ -713,7 +1054,7 @@ function renderEdgeLabels(
|
|
|
713
1054
|
|
|
714
1055
|
// When animated, add a wider invisible hover zone so labels appear on hover
|
|
715
1056
|
if (animate) {
|
|
716
|
-
const pathD =
|
|
1057
|
+
const pathD = buildPathD(wps, direction);
|
|
717
1058
|
g.insert('path', ':first-child')
|
|
718
1059
|
.attr('d', pathD)
|
|
719
1060
|
.attr('fill', 'none')
|
|
@@ -1311,21 +1652,6 @@ export function computeInfraLegendGroups(
|
|
|
1311
1652
|
return groups;
|
|
1312
1653
|
}
|
|
1313
1654
|
|
|
1314
|
-
/** Compute total width for the playback pill (speed only). */
|
|
1315
|
-
function computePlaybackWidth(playback: InfraPlaybackState | undefined): number {
|
|
1316
|
-
if (!playback) return 0;
|
|
1317
|
-
const pillWidth = 'Playback'.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
1318
|
-
if (!playback.expanded) return pillWidth;
|
|
1319
|
-
|
|
1320
|
-
let entriesW = 8; // gap after pill
|
|
1321
|
-
entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
|
|
1322
|
-
for (const s of playback.speedOptions) {
|
|
1323
|
-
entriesW += `${s}x`.length * LEGEND_ENTRY_FONT_W + SPEED_BADGE_H_PAD * 2 + SPEED_BADGE_GAP;
|
|
1324
|
-
}
|
|
1325
|
-
return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
/** Whether a separate Scenario pill should render. */
|
|
1329
1655
|
function renderLegend(
|
|
1330
1656
|
rootSvg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1331
1657
|
legendGroups: InfraLegendGroup[],
|
|
@@ -1334,21 +1660,21 @@ function renderLegend(
|
|
|
1334
1660
|
palette: PaletteColors,
|
|
1335
1661
|
isDark: boolean,
|
|
1336
1662
|
activeGroup: string | null,
|
|
1337
|
-
playback?: InfraPlaybackState,
|
|
1338
1663
|
) {
|
|
1339
|
-
if (legendGroups.length === 0
|
|
1664
|
+
if (legendGroups.length === 0) return;
|
|
1340
1665
|
|
|
1341
1666
|
const legendG = rootSvg.append('g')
|
|
1342
1667
|
.attr('transform', `translate(0, ${legendY})`);
|
|
1343
1668
|
|
|
1669
|
+
if (activeGroup) {
|
|
1670
|
+
legendG.attr('data-legend-active', activeGroup.toLowerCase());
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1344
1673
|
// Compute centered positions
|
|
1345
1674
|
const effectiveW = (g: InfraLegendGroup) =>
|
|
1346
1675
|
activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase() ? g.width : g.minifiedWidth;
|
|
1347
|
-
const playbackW = computePlaybackWidth(playback);
|
|
1348
|
-
const trailingGaps = legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
|
|
1349
1676
|
const totalLegendW = legendGroups.reduce((s, g) => s + effectiveW(g), 0)
|
|
1350
|
-
+ (legendGroups.length - 1) * LEGEND_GROUP_GAP
|
|
1351
|
-
+ trailingGaps + playbackW;
|
|
1677
|
+
+ (legendGroups.length - 1) * LEGEND_GROUP_GAP;
|
|
1352
1678
|
let cursorX = (totalWidth - totalLegendW) / 2;
|
|
1353
1679
|
|
|
1354
1680
|
for (const group of legendGroups) {
|
|
@@ -1366,7 +1692,6 @@ function renderLegend(
|
|
|
1366
1692
|
.attr('transform', `translate(${cursorX}, 0)`)
|
|
1367
1693
|
.attr('class', 'infra-legend-group')
|
|
1368
1694
|
.attr('data-legend-group', group.name.toLowerCase())
|
|
1369
|
-
.attr('data-legend-type', group.type)
|
|
1370
1695
|
.style('cursor', 'pointer');
|
|
1371
1696
|
|
|
1372
1697
|
// Outer capsule background (active only)
|
|
@@ -1400,7 +1725,7 @@ function renderLegend(
|
|
|
1400
1725
|
.attr('height', pillH)
|
|
1401
1726
|
.attr('rx', pillH / 2)
|
|
1402
1727
|
.attr('fill', 'none')
|
|
1403
|
-
.attr('stroke',
|
|
1728
|
+
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
1404
1729
|
.attr('stroke-width', 0.75);
|
|
1405
1730
|
}
|
|
1406
1731
|
|
|
@@ -1422,15 +1747,12 @@ function renderLegend(
|
|
|
1422
1747
|
const entryG = gEl
|
|
1423
1748
|
.append('g')
|
|
1424
1749
|
.attr('class', 'infra-legend-entry')
|
|
1425
|
-
.attr('data-legend-entry', entry.key)
|
|
1426
|
-
.attr('data-legend-type', group.type)
|
|
1750
|
+
.attr('data-legend-entry', entry.key.toLowerCase())
|
|
1427
1751
|
.attr('data-legend-color', entry.color)
|
|
1752
|
+
.attr('data-legend-type', group.type)
|
|
1753
|
+
.attr('data-legend-tag-group', group.type === 'tag' ? (group.tagKey ?? '') : null)
|
|
1428
1754
|
.style('cursor', 'pointer');
|
|
1429
1755
|
|
|
1430
|
-
if (group.type === 'tag' && group.tagKey) {
|
|
1431
|
-
entryG.attr('data-legend-tag-group', group.tagKey);
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
1756
|
entryG.append('circle')
|
|
1435
1757
|
.attr('cx', entryX + LEGEND_DOT_R)
|
|
1436
1758
|
.attr('cy', LEGEND_HEIGHT / 2)
|
|
@@ -1453,127 +1775,12 @@ function renderLegend(
|
|
|
1453
1775
|
cursorX += effectiveW(group) + LEGEND_GROUP_GAP;
|
|
1454
1776
|
}
|
|
1455
1777
|
|
|
1456
|
-
// Playback pill — speed + pause only
|
|
1457
|
-
if (playback) {
|
|
1458
|
-
const isExpanded = playback.expanded;
|
|
1459
|
-
const groupBg = isDark
|
|
1460
|
-
? mix(palette.bg, palette.text, 85)
|
|
1461
|
-
: mix(palette.bg, palette.text, 92);
|
|
1462
|
-
|
|
1463
|
-
const pillLabel = 'Playback';
|
|
1464
|
-
const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
1465
|
-
const fullW = computePlaybackWidth(playback);
|
|
1466
|
-
|
|
1467
|
-
const pbG = legendG
|
|
1468
|
-
.append('g')
|
|
1469
|
-
.attr('transform', `translate(${cursorX}, 0)`)
|
|
1470
|
-
.attr('class', 'infra-legend-group infra-playback-pill')
|
|
1471
|
-
.style('cursor', 'pointer');
|
|
1472
|
-
|
|
1473
|
-
if (isExpanded) {
|
|
1474
|
-
pbG.append('rect')
|
|
1475
|
-
.attr('width', fullW)
|
|
1476
|
-
.attr('height', LEGEND_HEIGHT)
|
|
1477
|
-
.attr('rx', LEGEND_HEIGHT / 2)
|
|
1478
|
-
.attr('fill', groupBg);
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
const pillXOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
|
|
1482
|
-
const pillYOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
|
|
1483
|
-
const pillH = LEGEND_HEIGHT - (isExpanded ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
1484
|
-
|
|
1485
|
-
pbG.append('rect')
|
|
1486
|
-
.attr('x', pillXOff).attr('y', pillYOff)
|
|
1487
|
-
.attr('width', pillWidth).attr('height', pillH)
|
|
1488
|
-
.attr('rx', pillH / 2)
|
|
1489
|
-
.attr('fill', isExpanded ? palette.bg : groupBg);
|
|
1490
|
-
|
|
1491
|
-
if (isExpanded) {
|
|
1492
|
-
pbG.append('rect')
|
|
1493
|
-
.attr('x', pillXOff).attr('y', pillYOff)
|
|
1494
|
-
.attr('width', pillWidth).attr('height', pillH)
|
|
1495
|
-
.attr('rx', pillH / 2)
|
|
1496
|
-
.attr('fill', 'none')
|
|
1497
|
-
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
1498
|
-
.attr('stroke-width', 0.75);
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
pbG.append('text')
|
|
1502
|
-
.attr('x', pillXOff + pillWidth / 2)
|
|
1503
|
-
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
1504
|
-
.attr('font-family', FONT_FAMILY)
|
|
1505
|
-
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
1506
|
-
.attr('font-weight', '500')
|
|
1507
|
-
.attr('fill', isExpanded ? palette.text : palette.textMuted)
|
|
1508
|
-
.attr('text-anchor', 'middle')
|
|
1509
|
-
.text(pillLabel);
|
|
1510
|
-
|
|
1511
|
-
if (isExpanded) {
|
|
1512
|
-
let entryX = pillXOff + pillWidth + 8;
|
|
1513
|
-
const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
|
|
1514
|
-
|
|
1515
|
-
const ppLabel = playback.paused ? '▶' : '⏸';
|
|
1516
|
-
pbG.append('text')
|
|
1517
|
-
.attr('x', entryX).attr('y', entryY)
|
|
1518
|
-
.attr('font-family', FONT_FAMILY)
|
|
1519
|
-
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
1520
|
-
.attr('fill', palette.textMuted)
|
|
1521
|
-
.attr('data-playback-action', 'toggle-pause')
|
|
1522
|
-
.style('cursor', 'pointer')
|
|
1523
|
-
.text(ppLabel);
|
|
1524
|
-
entryX += LEGEND_PILL_FONT_SIZE * 0.8 + 6;
|
|
1525
|
-
|
|
1526
|
-
for (const s of playback.speedOptions) {
|
|
1527
|
-
const label = `${s}x`;
|
|
1528
|
-
const isActive = playback.speed === s;
|
|
1529
|
-
const slotW = label.length * LEGEND_ENTRY_FONT_W + SPEED_BADGE_H_PAD * 2;
|
|
1530
|
-
const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
|
|
1531
|
-
const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
|
|
1532
|
-
|
|
1533
|
-
// Wrap in <g> with data attrs so a single element carries the action,
|
|
1534
|
-
// and both rect and text inherit the hit target cleanly.
|
|
1535
|
-
const speedG = pbG.append('g')
|
|
1536
|
-
.attr('data-playback-action', 'set-speed')
|
|
1537
|
-
.attr('data-playback-value', String(s))
|
|
1538
|
-
.style('cursor', 'pointer');
|
|
1539
|
-
|
|
1540
|
-
// Badge rect: filled for active, transparent hit-target for inactive
|
|
1541
|
-
speedG.append('rect')
|
|
1542
|
-
.attr('x', entryX)
|
|
1543
|
-
.attr('y', badgeY)
|
|
1544
|
-
.attr('width', slotW)
|
|
1545
|
-
.attr('height', badgeH)
|
|
1546
|
-
.attr('rx', badgeH / 2)
|
|
1547
|
-
.attr('fill', isActive ? palette.primary : 'transparent');
|
|
1548
|
-
|
|
1549
|
-
speedG.append('text')
|
|
1550
|
-
.attr('x', entryX + slotW / 2).attr('y', entryY)
|
|
1551
|
-
.attr('font-family', FONT_FAMILY)
|
|
1552
|
-
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
1553
|
-
.attr('font-weight', isActive ? '600' : '400')
|
|
1554
|
-
.attr('fill', isActive ? palette.bg : palette.textMuted)
|
|
1555
|
-
.attr('text-anchor', 'middle')
|
|
1556
|
-
.text(label);
|
|
1557
|
-
entryX += slotW + SPEED_BADGE_GAP;
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
cursorX += fullW + LEGEND_GROUP_GAP;
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
1778
|
}
|
|
1565
1779
|
|
|
1566
1780
|
// ============================================================
|
|
1567
1781
|
// Main render
|
|
1568
1782
|
// ============================================================
|
|
1569
1783
|
|
|
1570
|
-
export interface InfraPlaybackState {
|
|
1571
|
-
expanded: boolean;
|
|
1572
|
-
paused: boolean;
|
|
1573
|
-
speed: number;
|
|
1574
|
-
speedOptions: readonly number[];
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
1784
|
export function renderInfra(
|
|
1578
1785
|
container: HTMLDivElement,
|
|
1579
1786
|
layout: InfraLayoutResult,
|
|
@@ -1584,7 +1791,7 @@ export function renderInfra(
|
|
|
1584
1791
|
tagGroups?: InfraTagGroup[],
|
|
1585
1792
|
activeGroup?: string | null,
|
|
1586
1793
|
animate?: boolean,
|
|
1587
|
-
|
|
1794
|
+
_playback?: unknown,
|
|
1588
1795
|
expandedNodeIds?: Set<string> | null,
|
|
1589
1796
|
exportMode?: boolean,
|
|
1590
1797
|
collapsedNodes?: Set<string> | null,
|
|
@@ -1594,7 +1801,7 @@ export function renderInfra(
|
|
|
1594
1801
|
|
|
1595
1802
|
// Build legend groups
|
|
1596
1803
|
const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette, layout.edges);
|
|
1597
|
-
const hasLegend = legendGroups.length > 0
|
|
1804
|
+
const hasLegend = legendGroups.length > 0;
|
|
1598
1805
|
// In app mode (not export), legend is rendered as a separate fixed-size SVG
|
|
1599
1806
|
const fixedLegend = !exportMode && hasLegend;
|
|
1600
1807
|
const legendOffset = hasLegend && !fixedLegend ? LEGEND_HEIGHT : 0;
|
|
@@ -1677,7 +1884,7 @@ export function renderInfra(
|
|
|
1677
1884
|
|
|
1678
1885
|
// Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
|
|
1679
1886
|
renderGroups(svg, layout.groups, palette, isDark);
|
|
1680
|
-
renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
|
|
1887
|
+
renderEdgePaths(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
|
|
1681
1888
|
const fanoutSourceIds = collectFanoutSourceIds(layout.edges);
|
|
1682
1889
|
const scaledGroupIds = new Set<string>(
|
|
1683
1890
|
layout.groups
|
|
@@ -1692,7 +1899,7 @@ export function renderInfra(
|
|
|
1692
1899
|
if (shouldAnimate) {
|
|
1693
1900
|
renderRejectParticles(svg, layout.nodes);
|
|
1694
1901
|
}
|
|
1695
|
-
renderEdgeLabels(svg, layout.edges, palette, isDark, shouldAnimate);
|
|
1902
|
+
renderEdgeLabels(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
|
|
1696
1903
|
|
|
1697
1904
|
// Legend at bottom
|
|
1698
1905
|
if (hasLegend) {
|
|
@@ -1707,9 +1914,9 @@ export function renderInfra(
|
|
|
1707
1914
|
.attr('viewBox', `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`)
|
|
1708
1915
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
1709
1916
|
.style('display', 'block');
|
|
1710
|
-
renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null
|
|
1917
|
+
renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null);
|
|
1711
1918
|
} else {
|
|
1712
|
-
renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null
|
|
1919
|
+
renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null);
|
|
1713
1920
|
}
|
|
1714
1921
|
}
|
|
1715
1922
|
}
|