@diagrammo/dgmo 0.6.1 → 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 +160 -159
- package/dist/index.cjs +780 -147
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +780 -147
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +33 -50
- package/package.json +4 -3
- package/src/c4/layout.ts +68 -5
- package/src/cli.ts +124 -2
- package/src/er/classify.ts +206 -0
- package/src/er/layout.ts +259 -94
- package/src/er/renderer.ts +231 -17
- package/src/infra/layout.ts +60 -13
- package/src/infra/renderer.ts +375 -32
- package/src/initiative-status/layout.ts +46 -30
- 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
|
@@ -129,10 +129,352 @@ const REJECT_COUNT_MAX = 3;
|
|
|
129
129
|
// Edge path generator
|
|
130
130
|
// ============================================================
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
}
|
|
136
478
|
|
|
137
479
|
/** Compute the point on a node's border closest to an external target point. */
|
|
138
480
|
function nodeBorderPoint(
|
|
@@ -597,12 +939,15 @@ function renderEdgePaths(
|
|
|
597
939
|
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
598
940
|
edges: InfraLayoutEdge[],
|
|
599
941
|
nodes: InfraLayoutNode[],
|
|
942
|
+
groups: InfraLayoutGroup[],
|
|
600
943
|
palette: PaletteColors,
|
|
601
944
|
isDark: boolean,
|
|
602
945
|
animate: boolean,
|
|
946
|
+
direction: 'LR' | 'TB',
|
|
603
947
|
) {
|
|
604
948
|
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
605
949
|
const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
|
|
950
|
+
const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
|
|
606
951
|
|
|
607
952
|
for (const edge of edges) {
|
|
608
953
|
if (edge.points.length === 0) continue;
|
|
@@ -612,29 +957,13 @@ function renderEdgePaths(
|
|
|
612
957
|
const color = edgeColor(edge, palette);
|
|
613
958
|
const strokeW = edgeWidth();
|
|
614
959
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
pts = [...pts].reverse();
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// Prepend source border point and append target border point so edges
|
|
627
|
-
// visually connect to node boundaries (dagre waypoints float between nodes)
|
|
628
|
-
if (sourceNode && pts.length > 0) {
|
|
629
|
-
const bp = nodeBorderPoint(sourceNode, pts[0]);
|
|
630
|
-
pts = [bp, ...pts];
|
|
631
|
-
}
|
|
632
|
-
if (targetNode && pts.length > 0) {
|
|
633
|
-
const bp = nodeBorderPoint(targetNode, pts[pts.length - 1]);
|
|
634
|
-
pts = [...pts, bp];
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
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);
|
|
638
967
|
const edgeG = svg.append('g')
|
|
639
968
|
.attr('class', 'infra-edge')
|
|
640
969
|
.attr('data-line-number', edge.lineNumber);
|
|
@@ -674,16 +1003,30 @@ function renderEdgePaths(
|
|
|
674
1003
|
function renderEdgeLabels(
|
|
675
1004
|
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
676
1005
|
edges: InfraLayoutEdge[],
|
|
1006
|
+
nodes: InfraLayoutNode[],
|
|
1007
|
+
groups: InfraLayoutGroup[],
|
|
677
1008
|
palette: PaletteColors,
|
|
678
1009
|
isDark: boolean,
|
|
679
1010
|
animate: boolean,
|
|
1011
|
+
direction: 'LR' | 'TB',
|
|
680
1012
|
) {
|
|
1013
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
1014
|
+
const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
|
|
681
1015
|
for (const edge of edges) {
|
|
682
1016
|
if (edge.points.length === 0) continue;
|
|
683
1017
|
if (!edge.label) continue;
|
|
684
1018
|
|
|
685
|
-
const
|
|
686
|
-
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)];
|
|
687
1030
|
const labelText = edge.label;
|
|
688
1031
|
|
|
689
1032
|
const g = svg.append('g')
|
|
@@ -711,7 +1054,7 @@ function renderEdgeLabels(
|
|
|
711
1054
|
|
|
712
1055
|
// When animated, add a wider invisible hover zone so labels appear on hover
|
|
713
1056
|
if (animate) {
|
|
714
|
-
const pathD =
|
|
1057
|
+
const pathD = buildPathD(wps, direction);
|
|
715
1058
|
g.insert('path', ':first-child')
|
|
716
1059
|
.attr('d', pathD)
|
|
717
1060
|
.attr('fill', 'none')
|
|
@@ -1541,7 +1884,7 @@ export function renderInfra(
|
|
|
1541
1884
|
|
|
1542
1885
|
// Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
|
|
1543
1886
|
renderGroups(svg, layout.groups, palette, isDark);
|
|
1544
|
-
renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
|
|
1887
|
+
renderEdgePaths(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
|
|
1545
1888
|
const fanoutSourceIds = collectFanoutSourceIds(layout.edges);
|
|
1546
1889
|
const scaledGroupIds = new Set<string>(
|
|
1547
1890
|
layout.groups
|
|
@@ -1556,7 +1899,7 @@ export function renderInfra(
|
|
|
1556
1899
|
if (shouldAnimate) {
|
|
1557
1900
|
renderRejectParticles(svg, layout.nodes);
|
|
1558
1901
|
}
|
|
1559
|
-
renderEdgeLabels(svg, layout.edges, palette, isDark, shouldAnimate);
|
|
1902
|
+
renderEdgeLabels(svg, layout.edges, layout.nodes, layout.groups, palette, isDark, shouldAnimate, layout.direction);
|
|
1560
1903
|
|
|
1561
1904
|
// Legend at bottom
|
|
1562
1905
|
if (hasLegend) {
|
|
@@ -28,8 +28,8 @@ export interface ISLayoutEdge {
|
|
|
28
28
|
status: import('./types').InitiativeStatus;
|
|
29
29
|
lineNumber: number;
|
|
30
30
|
// Layout contract for points[]:
|
|
31
|
-
// Back-edges:
|
|
32
|
-
//
|
|
31
|
+
// Back-edges: 5 points — [src.top/bottom_center, depart_ctrl, arc_control, approach_ctrl, tgt.top/bottom_center]
|
|
32
|
+
// Top/bottom-exit: 4 points — [src.top/bottom_center, depart_ctrl, tgt_approach, tgt.left_center]
|
|
33
33
|
// 4-point elbow: points[0] and points[last] pinned at node center Y; interior fans via yOffset
|
|
34
34
|
// fixedDagrePoints: points[0]=src.right, points[last]=tgt.left; interior from dagre
|
|
35
35
|
points: { x: number; y: number }[];
|
|
@@ -81,6 +81,7 @@ const PARALLEL_EDGE_MARGIN = 12; // total vertical margin reserved at top+bottom
|
|
|
81
81
|
const MAX_PARALLEL_EDGES = 5; // at most this many edges rendered between any directed source→target pair
|
|
82
82
|
const BACK_EDGE_MARGIN = 40; // clearance below/above nodes for back-edge arcs (~half NODESEP)
|
|
83
83
|
const BACK_EDGE_MIN_SPREAD = Math.round(NODE_WIDTH * 0.75); // minimum horizontal arc spread for near-same-X back-edges
|
|
84
|
+
const TOP_EXIT_STEP = 10; // px: control-point offset giving near-vertical departure tangent for top/bottom-exit elbows
|
|
84
85
|
const CHAR_WIDTH_RATIO = 0.6;
|
|
85
86
|
const NODE_FONT_SIZE = 13;
|
|
86
87
|
const NODE_TEXT_PADDING = 12;
|
|
@@ -221,15 +222,14 @@ export function layoutInitiativeStatus(
|
|
|
221
222
|
const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
|
|
222
223
|
const dagrePoints: { x: number; y: number }[] = dagreEdge?.points ?? [];
|
|
223
224
|
const hasIntermediateRank = allNodeX.some((x) => x > src.x + 20 && x < tgt.x - 20);
|
|
224
|
-
const step = Math.min((enterX - exitX) * 0.15, 20);
|
|
225
|
+
const step = Math.max(0, Math.min((enterX - exitX) * 0.15, 20)); // clamped ≥0: guards overlapping nodes
|
|
225
226
|
|
|
226
|
-
//
|
|
227
|
-
const isBackEdge
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// original use case (fan targets far below source in the same adjacent rank).
|
|
227
|
+
// 5-branch routing: isBackEdge → isTopExit → isBottomExit → 4-point elbow → fixedDagrePoints
|
|
228
|
+
const isBackEdge = tgt.x < src.x - 5; // 5px epsilon: same-rank same-X nodes must not false-match
|
|
229
|
+
// Guards: tgt.x > src.x (strict) keeps step positive; !hasIntermediateRank defers multi-rank
|
|
230
|
+
// displaced edges to fixedDagrePoints so dagre can route around intermediate nodes.
|
|
231
|
+
const isTopExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y < src.y - NODESEP;
|
|
232
|
+
const isBottomExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y > src.y + NODESEP;
|
|
233
233
|
|
|
234
234
|
let points: { x: number; y: number }[];
|
|
235
235
|
|
|
@@ -248,36 +248,52 @@ export function layoutInitiativeStatus(
|
|
|
248
248
|
? rawMidX + spreadDir * BACK_EDGE_MIN_SPREAD
|
|
249
249
|
: rawMidX;
|
|
250
250
|
const midX = Math.min(src.x, Math.max(tgt.x, unclamped));
|
|
251
|
+
// Clamped departure/approach control points give near-orthogonal tangents at node edges.
|
|
252
|
+
// For narrow back-edges (|src.x - tgt.x| < 2*TOP_EXIT_STEP), clamps degrade to midX±1 — valid.
|
|
253
|
+
const srcDepart = Math.max(midX + 1, src.x - TOP_EXIT_STEP);
|
|
254
|
+
const tgtApproach = Math.min(midX - 1, tgt.x + TOP_EXIT_STEP);
|
|
251
255
|
if (routeAbove) {
|
|
252
256
|
const arcY = Math.min(src.y - srcHalfH, tgt.y - tgtHalfH) - BACK_EDGE_MARGIN;
|
|
253
257
|
points = [
|
|
254
|
-
{ x: src.x,
|
|
255
|
-
{ x:
|
|
256
|
-
{ x:
|
|
258
|
+
{ x: src.x, y: src.y - srcHalfH },
|
|
259
|
+
{ x: srcDepart, y: src.y - srcHalfH - TOP_EXIT_STEP },
|
|
260
|
+
{ x: midX, y: arcY },
|
|
261
|
+
{ x: tgtApproach, y: tgt.y - tgtHalfH - TOP_EXIT_STEP },
|
|
262
|
+
{ x: tgt.x, y: tgt.y - tgtHalfH },
|
|
257
263
|
];
|
|
258
264
|
} else {
|
|
259
265
|
const arcY = Math.max(src.y + srcHalfH, tgt.y + tgtHalfH) + BACK_EDGE_MARGIN;
|
|
260
266
|
points = [
|
|
261
|
-
{ x: src.x,
|
|
262
|
-
{ x:
|
|
263
|
-
{ x:
|
|
267
|
+
{ x: src.x, y: src.y + srcHalfH },
|
|
268
|
+
{ x: srcDepart, y: src.y + srcHalfH + TOP_EXIT_STEP },
|
|
269
|
+
{ x: midX, y: arcY },
|
|
270
|
+
{ x: tgtApproach, y: tgt.y + tgtHalfH + TOP_EXIT_STEP },
|
|
271
|
+
{ x: tgt.x, y: tgt.y + tgtHalfH },
|
|
264
272
|
];
|
|
265
273
|
}
|
|
266
|
-
} else if (
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
const spreadEntryY = tgt.y + yOffset;
|
|
275
|
-
const midX = (spreadExitX + enterX) / 2; // always monotone ✓ (yOffset << node gap)
|
|
276
|
-
const midY = (exitY + spreadEntryY) / 2;
|
|
274
|
+
} else if (isTopExit) {
|
|
275
|
+
// 4-point top-exit elbow: exits top of source ~vertically, arrives left of target horizontally.
|
|
276
|
+
// Top exit keeps this edge ABOVE the horizontal right-exit bundle → avoids crossings.
|
|
277
|
+
// yOffset repurposed as X-spread for top/bottom-exit branches (same magnitude, different axis).
|
|
278
|
+
// p1x: floor at src.x prevents negative-yOffset edges from going left of origin (breaks monotone X);
|
|
279
|
+
// ceiling at midpoint-1 prevents overshooting for large positive yOffset (±32px for 5 parallel edges).
|
|
280
|
+
const exitY = src.y - src.height / 2;
|
|
281
|
+
const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
|
|
277
282
|
points = [
|
|
278
|
-
{ x:
|
|
279
|
-
{ x:
|
|
280
|
-
{ x: enterX,
|
|
283
|
+
{ x: src.x, y: exitY },
|
|
284
|
+
{ x: p1x, y: exitY - TOP_EXIT_STEP },
|
|
285
|
+
{ x: enterX - step, y: tgt.y + yOffset },
|
|
286
|
+
{ x: enterX, y: tgt.y },
|
|
287
|
+
];
|
|
288
|
+
} else if (isBottomExit) {
|
|
289
|
+
// 4-point bottom-exit elbow: mirror of top-exit. Keeps edge BELOW the horizontal bundle.
|
|
290
|
+
const exitY = src.y + src.height / 2;
|
|
291
|
+
const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
|
|
292
|
+
points = [
|
|
293
|
+
{ x: src.x, y: exitY },
|
|
294
|
+
{ x: p1x, y: exitY + TOP_EXIT_STEP },
|
|
295
|
+
{ x: enterX - step, y: tgt.y + yOffset },
|
|
296
|
+
{ x: enterX, y: tgt.y },
|
|
281
297
|
];
|
|
282
298
|
} else if (tgt.x > src.x && !hasIntermediateRank) {
|
|
283
299
|
// 4-point elbow: adjacent-rank forward edges (unchanged)
|