@diagrammo/dgmo 0.8.2 → 0.8.4
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-diagram-this.md +60 -0
- package/.claude/commands/dgmo-document-project.md +128 -0
- package/.claude/commands/dgmo.md +185 -50
- package/.cursorrules +32 -37
- package/.github/copilot-instructions.md +35 -44
- package/.windsurfrules +32 -37
- package/README.md +4 -4
- package/dist/cli.cjs +189 -194
- package/dist/editor.cjs +336 -0
- package/dist/editor.cjs.map +1 -0
- package/dist/editor.d.cts +27 -0
- package/dist/editor.d.ts +27 -0
- package/dist/editor.js +305 -0
- package/dist/editor.js.map +1 -0
- package/dist/index.cjs +3699 -1564
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -6
- package/dist/index.d.ts +7 -6
- package/dist/index.js +3699 -1564
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +822 -1060
- package/gallery/fixtures/arc.dgmo +18 -0
- package/gallery/fixtures/area.dgmo +19 -0
- package/gallery/fixtures/bar-stacked.dgmo +10 -0
- package/gallery/fixtures/bar.dgmo +10 -0
- package/gallery/fixtures/c4-full.dgmo +52 -0
- package/gallery/fixtures/c4.dgmo +17 -0
- package/gallery/fixtures/chord.dgmo +12 -0
- package/gallery/fixtures/class-basic.dgmo +14 -0
- package/gallery/fixtures/class-full.dgmo +43 -0
- package/gallery/fixtures/doughnut.dgmo +8 -0
- package/gallery/fixtures/flowchart-basic.dgmo +3 -0
- package/gallery/fixtures/flowchart-colors.dgmo +5 -0
- package/gallery/fixtures/flowchart-complex.dgmo +17 -0
- package/gallery/fixtures/flowchart-decision.dgmo +5 -0
- package/gallery/fixtures/flowchart-full.dgmo +13 -0
- package/gallery/fixtures/flowchart-groups.dgmo +10 -0
- package/gallery/fixtures/flowchart-loop.dgmo +7 -0
- package/gallery/fixtures/flowchart-nested.dgmo +7 -0
- package/gallery/fixtures/flowchart-shapes.dgmo +5 -0
- package/gallery/fixtures/function.dgmo +8 -0
- package/gallery/fixtures/funnel.dgmo +7 -0
- package/gallery/fixtures/gantt-full.dgmo +49 -0
- package/gallery/fixtures/gantt.dgmo +42 -0
- package/gallery/fixtures/heatmap.dgmo +8 -0
- package/gallery/fixtures/infra-full.dgmo +78 -0
- package/gallery/fixtures/infra-overload.dgmo +25 -0
- package/gallery/fixtures/infra.dgmo +47 -0
- package/gallery/fixtures/initiative-status-full.dgmo +46 -0
- package/gallery/fixtures/initiative-status-phases.dgmo +29 -0
- package/gallery/fixtures/initiative-status.dgmo +9 -0
- package/gallery/fixtures/line.dgmo +19 -0
- package/gallery/fixtures/multi-line.dgmo +11 -0
- package/gallery/fixtures/org-basic.dgmo +16 -0
- package/gallery/fixtures/org-full.dgmo +69 -0
- package/gallery/fixtures/org-teams.dgmo +25 -0
- package/gallery/fixtures/pie.dgmo +9 -0
- package/gallery/fixtures/polar-area.dgmo +8 -0
- package/gallery/fixtures/quadrant.dgmo +18 -0
- package/gallery/fixtures/radar.dgmo +8 -0
- package/gallery/fixtures/sankey.dgmo +31 -0
- package/gallery/fixtures/scatter.dgmo +21 -0
- package/gallery/fixtures/sequence-tags-protocols.dgmo +45 -0
- package/gallery/fixtures/sequence-tags.dgmo +41 -0
- package/gallery/fixtures/sequence.dgmo +35 -0
- package/gallery/fixtures/sitemap-basic.dgmo +12 -0
- package/gallery/fixtures/sitemap-full.dgmo +156 -0
- package/gallery/fixtures/slope.dgmo +8 -0
- package/gallery/fixtures/spr-eras.dgmo +62 -0
- package/gallery/fixtures/state.dgmo +30 -0
- package/gallery/fixtures/timeline-intraday.dgmo +14 -0
- package/gallery/fixtures/timeline.dgmo +32 -0
- package/gallery/fixtures/venn.dgmo +10 -0
- package/gallery/fixtures/wordcloud.dgmo +24 -0
- package/package.json +51 -2
- package/src/c4/layout.ts +372 -90
- package/src/c4/parser.ts +113 -62
- package/src/chart.ts +149 -64
- package/src/class/parser.ts +84 -28
- package/src/class/renderer.ts +2 -2
- package/src/cli.ts +179 -77
- package/src/completion.ts +381 -182
- package/src/d3.ts +1026 -428
- package/src/dgmo-mermaid.ts +16 -13
- package/src/dgmo-router.ts +70 -24
- package/src/echarts.ts +682 -169
- package/src/editor/dgmo.grammar +69 -0
- package/src/editor/dgmo.grammar.d.ts +2 -0
- package/src/editor/dgmo.grammar.js +18 -0
- package/src/editor/dgmo.grammar.terms.d.ts +5 -0
- package/src/editor/dgmo.grammar.terms.js +35 -0
- package/src/editor/highlight.ts +36 -0
- package/src/editor/index.ts +28 -0
- package/src/editor/keywords.ts +220 -0
- package/src/editor/tokens.ts +30 -0
- package/src/er/parser.ts +55 -29
- package/src/er/renderer.ts +112 -53
- package/src/gantt/calculator.ts +91 -29
- package/src/gantt/parser.ts +291 -97
- package/src/gantt/renderer.ts +1120 -350
- package/src/graph/flowchart-parser.ts +48 -75
- package/src/graph/state-parser.ts +54 -27
- package/src/infra/parser.ts +161 -177
- package/src/infra/renderer.ts +723 -271
- package/src/infra/types.ts +0 -1
- package/src/initiative-status/parser.ts +144 -56
- package/src/kanban/parser.ts +27 -19
- package/src/org/layout.ts +111 -44
- package/src/org/parser.ts +71 -27
- package/src/org/resolver.ts +3 -3
- package/src/palettes/index.ts +3 -2
- package/src/render.ts +1 -2
- package/src/sequence/parser.ts +209 -100
- package/src/sitemap/parser.ts +73 -44
- package/src/utils/arrows.ts +2 -22
- package/src/utils/duration.ts +39 -21
- package/src/utils/legend-constants.ts +0 -2
- package/src/utils/parsing.ts +82 -72
- package/src/utils/tag-groups.ts +4 -41
- package/src/infra/serialize.ts +0 -67
package/src/infra/renderer.ts
CHANGED
|
@@ -9,8 +9,18 @@ import type { PaletteColors } from '../palettes';
|
|
|
9
9
|
import { mix } from '../palettes/color-utils';
|
|
10
10
|
import type { InfraTagGroup } from './types';
|
|
11
11
|
import { resolveColor } from '../colors';
|
|
12
|
-
import type {
|
|
13
|
-
|
|
12
|
+
import type {
|
|
13
|
+
InfraLayoutResult,
|
|
14
|
+
InfraLayoutNode,
|
|
15
|
+
InfraLayoutEdge,
|
|
16
|
+
InfraLayoutGroup,
|
|
17
|
+
} from './layout';
|
|
18
|
+
import {
|
|
19
|
+
inferRoles,
|
|
20
|
+
collectDiagramRoles,
|
|
21
|
+
collectFanoutSourceIds,
|
|
22
|
+
FANOUT_ROLE,
|
|
23
|
+
} from './roles';
|
|
14
24
|
import { parseInfra } from './parser';
|
|
15
25
|
import { computeInfra } from './compute';
|
|
16
26
|
import { layoutInfra } from './layout';
|
|
@@ -26,7 +36,12 @@ import {
|
|
|
26
36
|
LEGEND_GROUP_GAP,
|
|
27
37
|
measureLegendText,
|
|
28
38
|
} from '../utils/legend-constants';
|
|
29
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
TITLE_FONT_SIZE,
|
|
41
|
+
TITLE_FONT_WEIGHT,
|
|
42
|
+
TITLE_Y,
|
|
43
|
+
TITLE_OFFSET,
|
|
44
|
+
} from '../utils/title-constants';
|
|
30
45
|
|
|
31
46
|
// ============================================================
|
|
32
47
|
// Constants
|
|
@@ -51,7 +66,7 @@ const COLLAPSE_BAR_INSET = 0;
|
|
|
51
66
|
const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
|
|
52
67
|
const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
|
|
53
68
|
const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
|
|
54
|
-
const SPEED_BADGE_GAP = 6;
|
|
69
|
+
const SPEED_BADGE_GAP = 6; // gap between speed option slots
|
|
55
70
|
|
|
56
71
|
// Health colors (from UX spec)
|
|
57
72
|
const COLOR_HEALTHY = '#22c55e';
|
|
@@ -60,24 +75,39 @@ const COLOR_OVERLOADED = '#ef4444';
|
|
|
60
75
|
/** SLO thresholds resolved for a single node (chart-level + per-node override). */
|
|
61
76
|
interface NodeSlo {
|
|
62
77
|
availThreshold: number | null; // fraction e.g. 0.999
|
|
63
|
-
latencyP90: number | null;
|
|
64
|
-
warningMargin: number;
|
|
78
|
+
latencyP90: number | null; // ms e.g. 200
|
|
79
|
+
warningMargin: number; // fraction e.g. 0.05
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
/** Resolve effective SLO for a node: per-node properties take precedence over chart-level options.
|
|
68
83
|
* Returns null if neither availThreshold nor latencyP90 is declared. */
|
|
69
|
-
function resolveNodeSlo(
|
|
84
|
+
function resolveNodeSlo(
|
|
85
|
+
node: InfraLayoutNode,
|
|
86
|
+
diagramOptions: Record<string, string>
|
|
87
|
+
): NodeSlo | null {
|
|
70
88
|
const nodeProp = (key: string) => node.properties.find((p) => p.key === key);
|
|
71
89
|
|
|
72
|
-
const availRaw =
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
const availRaw =
|
|
91
|
+
nodeProp('slo-availability')?.value ?? diagramOptions['slo-availability'];
|
|
92
|
+
const latencyRaw =
|
|
93
|
+
nodeProp('slo-p90-latency-ms')?.value ??
|
|
94
|
+
diagramOptions['slo-p90-latency-ms'];
|
|
95
|
+
const marginRaw =
|
|
96
|
+
nodeProp('slo-warning-margin')?.value ??
|
|
97
|
+
diagramOptions['slo-warning-margin'];
|
|
98
|
+
|
|
99
|
+
const availParsed =
|
|
100
|
+
availRaw != null
|
|
101
|
+
? parseFloat(String(availRaw).replace('%', '')) / 100
|
|
102
|
+
: NaN;
|
|
77
103
|
const availThreshold = !isNaN(availParsed) ? availParsed : null;
|
|
78
|
-
const latencyParsed =
|
|
104
|
+
const latencyParsed =
|
|
105
|
+
latencyRaw != null ? parseFloat(String(latencyRaw)) : NaN;
|
|
79
106
|
const latencyP90 = !isNaN(latencyParsed) ? latencyParsed : null;
|
|
80
|
-
const marginParsed =
|
|
107
|
+
const marginParsed =
|
|
108
|
+
marginRaw != null
|
|
109
|
+
? parseFloat(String(marginRaw).replace('%', '')) / 100
|
|
110
|
+
: NaN;
|
|
81
111
|
const warningMargin = !isNaN(marginParsed) ? marginParsed : 0.05;
|
|
82
112
|
|
|
83
113
|
if (availThreshold == null && latencyP90 == null) return null;
|
|
@@ -103,19 +133,19 @@ interface ComputedRow {
|
|
|
103
133
|
}
|
|
104
134
|
|
|
105
135
|
// Animation constants
|
|
106
|
-
const FLOW_SPEED_MIN = 2.5;
|
|
107
|
-
const FLOW_SPEED_MAX = 6;
|
|
108
|
-
const PARTICLE_R = 5;
|
|
109
|
-
const PARTICLE_COUNT_MIN = 1;
|
|
110
|
-
const PARTICLE_COUNT_MAX = 4;
|
|
111
|
-
const NODE_PULSE_SPEED = 1.5;
|
|
112
|
-
const NODE_PULSE_OVERLOAD = 0.7;
|
|
136
|
+
const FLOW_SPEED_MIN = 2.5; // seconds at max RPS
|
|
137
|
+
const FLOW_SPEED_MAX = 6; // seconds at min RPS
|
|
138
|
+
const PARTICLE_R = 5; // particle circle radius
|
|
139
|
+
const PARTICLE_COUNT_MIN = 1; // min particles per edge
|
|
140
|
+
const PARTICLE_COUNT_MAX = 4; // max particles per edge (at max RPS)
|
|
141
|
+
const NODE_PULSE_SPEED = 1.5; // seconds for warning pulse
|
|
142
|
+
const NODE_PULSE_OVERLOAD = 0.7; // seconds for overload pulse
|
|
113
143
|
|
|
114
144
|
// Reject particle constants
|
|
115
145
|
const REJECT_PARTICLE_R = PARTICLE_R;
|
|
116
|
-
const REJECT_DROP_DISTANCE = 30;
|
|
117
|
-
const REJECT_DURATION_MIN = 1.5;
|
|
118
|
-
const REJECT_DURATION_MAX = 3;
|
|
146
|
+
const REJECT_DROP_DISTANCE = 30; // px downward travel
|
|
147
|
+
const REJECT_DURATION_MIN = 1.5; // seconds per drop at max reject
|
|
148
|
+
const REJECT_DURATION_MAX = 3; // seconds per drop at min reject
|
|
119
149
|
const REJECT_COUNT_MIN = 1;
|
|
120
150
|
const REJECT_COUNT_MAX = 3;
|
|
121
151
|
|
|
@@ -129,7 +159,10 @@ type Pt = { x: number; y: number };
|
|
|
129
159
|
* 2-point paths use curveBumpX/Y (nice S-curve).
|
|
130
160
|
* Multi-point obstacle-avoiding paths use CatmullRom for a smooth fit. */
|
|
131
161
|
function buildPathD(pts: Pt[], direction: 'LR' | 'TB'): string {
|
|
132
|
-
const gen = d3Shape
|
|
162
|
+
const gen = d3Shape
|
|
163
|
+
.line<Pt>()
|
|
164
|
+
.x((d) => d.x)
|
|
165
|
+
.y((d) => d.y);
|
|
133
166
|
if (pts.length <= 2) {
|
|
134
167
|
gen.curve(direction === 'TB' ? d3Shape.curveBumpY : d3Shape.curveBumpX);
|
|
135
168
|
} else {
|
|
@@ -153,7 +186,7 @@ type Rect = { x: number; y: number; width: number; height: number };
|
|
|
153
186
|
function computePortPts(
|
|
154
187
|
edges: InfraLayoutEdge[],
|
|
155
188
|
nodeMap: Map<string, InfraLayoutNode>,
|
|
156
|
-
direction: 'LR' | 'TB'
|
|
189
|
+
direction: 'LR' | 'TB'
|
|
157
190
|
): { srcPts: Map<string, Pt>; tgtPts: Map<string, Pt> } {
|
|
158
191
|
const srcPts = new Map<string, Pt>();
|
|
159
192
|
const tgtPts = new Map<string, Pt>();
|
|
@@ -173,22 +206,28 @@ function computePortPts(
|
|
|
173
206
|
if (!source) continue;
|
|
174
207
|
const sorted = es
|
|
175
208
|
.map((e) => ({ e, t: nodeMap.get(e.targetId) }))
|
|
176
|
-
.filter(
|
|
209
|
+
.filter(
|
|
210
|
+
(x): x is { e: InfraLayoutEdge; t: InfraLayoutNode } => x.t != null
|
|
211
|
+
)
|
|
177
212
|
.sort((a, b) => (direction === 'LR' ? a.t.y - b.t.y : a.t.x - b.t.x));
|
|
178
213
|
const n = sorted.length;
|
|
179
214
|
for (let i = 0; i < n; i++) {
|
|
180
|
-
const frac = n === 1 ? 0.5 : PAD + (1 - 2 * PAD) * i / (n - 1);
|
|
215
|
+
const frac = n === 1 ? 0.5 : PAD + ((1 - 2 * PAD) * i) / (n - 1);
|
|
181
216
|
const { e, t } = sorted[i];
|
|
182
217
|
const isBackward = direction === 'LR' ? t.x < source.x : t.y < source.y;
|
|
183
218
|
if (direction === 'LR') {
|
|
184
219
|
srcPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
185
|
-
x: isBackward
|
|
220
|
+
x: isBackward
|
|
221
|
+
? source.x - source.width / 2
|
|
222
|
+
: source.x + source.width / 2,
|
|
186
223
|
y: source.y - source.height / 2 + frac * source.height,
|
|
187
224
|
});
|
|
188
225
|
} else {
|
|
189
226
|
srcPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
190
227
|
x: source.x - source.width / 2 + frac * source.width,
|
|
191
|
-
y: isBackward
|
|
228
|
+
y: isBackward
|
|
229
|
+
? source.y - source.height / 2
|
|
230
|
+
: source.y + source.height / 2,
|
|
192
231
|
});
|
|
193
232
|
}
|
|
194
233
|
}
|
|
@@ -206,22 +245,28 @@ function computePortPts(
|
|
|
206
245
|
if (!target) continue;
|
|
207
246
|
const sorted = es
|
|
208
247
|
.map((e) => ({ e, s: nodeMap.get(e.sourceId) }))
|
|
209
|
-
.filter(
|
|
248
|
+
.filter(
|
|
249
|
+
(x): x is { e: InfraLayoutEdge; s: InfraLayoutNode } => x.s != null
|
|
250
|
+
)
|
|
210
251
|
.sort((a, b) => (direction === 'LR' ? a.s.y - b.s.y : a.s.x - b.s.x));
|
|
211
252
|
const n = sorted.length;
|
|
212
253
|
for (let i = 0; i < n; i++) {
|
|
213
|
-
const frac = n === 1 ? 0.5 : PAD + (1 - 2 * PAD) * i / (n - 1);
|
|
254
|
+
const frac = n === 1 ? 0.5 : PAD + ((1 - 2 * PAD) * i) / (n - 1);
|
|
214
255
|
const { e, s } = sorted[i];
|
|
215
256
|
const isBackward = direction === 'LR' ? target.x < s.x : target.y < s.y;
|
|
216
257
|
if (direction === 'LR') {
|
|
217
258
|
tgtPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
218
|
-
x: isBackward
|
|
259
|
+
x: isBackward
|
|
260
|
+
? target.x + target.width / 2
|
|
261
|
+
: target.x - target.width / 2,
|
|
219
262
|
y: target.y - target.height / 2 + frac * target.height,
|
|
220
263
|
});
|
|
221
264
|
} else {
|
|
222
265
|
tgtPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
223
266
|
x: target.x - target.width / 2 + frac * target.width,
|
|
224
|
-
y: isBackward
|
|
267
|
+
y: isBackward
|
|
268
|
+
? target.y + target.height / 2
|
|
269
|
+
: target.y - target.height / 2,
|
|
225
270
|
});
|
|
226
271
|
}
|
|
227
272
|
}
|
|
@@ -239,12 +284,14 @@ function computePortPts(
|
|
|
239
284
|
function findRoutingLane(
|
|
240
285
|
blocking: Rect[],
|
|
241
286
|
targetY: number,
|
|
242
|
-
margin: number
|
|
287
|
+
margin: number
|
|
243
288
|
): number {
|
|
244
289
|
// Use a small slop for merging so closely-spaced (but distinct) groups
|
|
245
290
|
// stay as separate intervals and the gap between them can be threaded.
|
|
246
291
|
const MERGE_SLOP = 4;
|
|
247
|
-
const sorted = [...blocking].sort(
|
|
292
|
+
const sorted = [...blocking].sort(
|
|
293
|
+
(a, b) => a.y + a.height / 2 - (b.y + b.height / 2)
|
|
294
|
+
);
|
|
248
295
|
const merged: [number, number][] = [];
|
|
249
296
|
for (const r of sorted) {
|
|
250
297
|
const lo = r.y - MERGE_SLOP;
|
|
@@ -261,25 +308,30 @@ function findRoutingLane(
|
|
|
261
308
|
// MIN_GAP: allow narrow gaps (edge is ~1.5px, so even 10px clearance is fine).
|
|
262
309
|
const MIN_GAP = 10;
|
|
263
310
|
const candidates: number[] = [
|
|
264
|
-
merged[0][0] - margin,
|
|
265
|
-
merged[merged.length - 1][1] + margin,
|
|
311
|
+
merged[0][0] - margin, // above all blocking rects
|
|
312
|
+
merged[merged.length - 1][1] + margin, // below all blocking rects
|
|
266
313
|
];
|
|
267
314
|
for (let i = 0; i < merged.length - 1; i++) {
|
|
268
315
|
const gapLo = merged[i][1];
|
|
269
316
|
const gapHi = merged[i + 1][0];
|
|
270
317
|
if (gapHi - gapLo >= MIN_GAP) {
|
|
271
|
-
candidates.push((gapLo + gapHi) / 2);
|
|
318
|
+
candidates.push((gapLo + gapHi) / 2); // thread through the gap
|
|
272
319
|
}
|
|
273
320
|
}
|
|
274
321
|
|
|
275
322
|
// Return the candidate closest to targetY (tightest arc)
|
|
276
|
-
return candidates.reduce(
|
|
277
|
-
Math.abs(c - targetY) < Math.abs(best - targetY) ? c : best,
|
|
278
|
-
candidates[0]
|
|
323
|
+
return candidates.reduce(
|
|
324
|
+
(best, c) => (Math.abs(c - targetY) < Math.abs(best - targetY) ? c : best),
|
|
325
|
+
candidates[0]
|
|
326
|
+
);
|
|
279
327
|
}
|
|
280
328
|
|
|
281
329
|
/** Check whether segment p1→p2 passes through (or has an endpoint inside) the rectangle. */
|
|
282
|
-
function segmentIntersectsRect(
|
|
330
|
+
function segmentIntersectsRect(
|
|
331
|
+
p1: Pt,
|
|
332
|
+
p2: Pt,
|
|
333
|
+
rect: { x: number; y: number; width: number; height: number }
|
|
334
|
+
): boolean {
|
|
283
335
|
const { x: rx, y: ry, width: rw, height: rh } = rect;
|
|
284
336
|
const rr = rx + rw;
|
|
285
337
|
const rb = ry + rh;
|
|
@@ -290,21 +342,26 @@ function segmentIntersectsRect(p1: Pt, p2: Pt, rect: { x: number; y: number; wid
|
|
|
290
342
|
if (Math.max(p1.x, p2.x) < rx || Math.min(p1.x, p2.x) > rr) return false;
|
|
291
343
|
if (Math.max(p1.y, p2.y) < ry || Math.min(p1.y, p2.y) > rb) return false;
|
|
292
344
|
// Cross product sign helper (z-component of cross product)
|
|
293
|
-
const cross = (o: Pt, a: Pt, b: Pt) =>
|
|
345
|
+
const cross = (o: Pt, a: Pt, b: Pt) =>
|
|
346
|
+
(a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
294
347
|
// Does segment p1p2 cross segment a→b?
|
|
295
348
|
const crosses = (a: Pt, b: Pt) => {
|
|
296
349
|
const d1 = cross(a, b, p1);
|
|
297
350
|
const d2 = cross(a, b, p2);
|
|
298
351
|
const d3 = cross(p1, p2, a);
|
|
299
352
|
const d4 = cross(p1, p2, b);
|
|
300
|
-
return (
|
|
301
|
-
|
|
353
|
+
return (
|
|
354
|
+
((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
|
355
|
+
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
|
|
356
|
+
);
|
|
302
357
|
};
|
|
303
358
|
const tl: Pt = { x: rx, y: ry };
|
|
304
359
|
const tr: Pt = { x: rr, y: ry };
|
|
305
360
|
const br: Pt = { x: rr, y: rb };
|
|
306
361
|
const bl: Pt = { x: rx, y: rb };
|
|
307
|
-
return
|
|
362
|
+
return (
|
|
363
|
+
crosses(tl, tr) || crosses(tr, br) || crosses(br, bl) || crosses(bl, tl)
|
|
364
|
+
);
|
|
308
365
|
}
|
|
309
366
|
|
|
310
367
|
/** Check whether the curveBumpX/Y S-curve from sc to tc intersects rect.
|
|
@@ -315,21 +372,30 @@ function segmentIntersectsRect(p1: Pt, p2: Pt, rect: { x: number; y: number; wid
|
|
|
315
372
|
* sc → (midX, sc.y) → (midX, tc.y) → tc
|
|
316
373
|
*
|
|
317
374
|
* curveBumpY (TB) mirrors this on the other axis. */
|
|
318
|
-
function curveIntersectsRect(
|
|
375
|
+
function curveIntersectsRect(
|
|
376
|
+
sc: Pt,
|
|
377
|
+
tc: Pt,
|
|
378
|
+
rect: Rect,
|
|
379
|
+
direction: 'LR' | 'TB'
|
|
380
|
+
): boolean {
|
|
319
381
|
if (direction === 'LR') {
|
|
320
382
|
const midX = (sc.x + tc.x) / 2;
|
|
321
383
|
const m1: Pt = { x: midX, y: sc.y };
|
|
322
384
|
const m2: Pt = { x: midX, y: tc.y };
|
|
323
|
-
return
|
|
324
|
-
|
|
325
|
-
|
|
385
|
+
return (
|
|
386
|
+
segmentIntersectsRect(sc, m1, rect) ||
|
|
387
|
+
segmentIntersectsRect(m1, m2, rect) ||
|
|
388
|
+
segmentIntersectsRect(m2, tc, rect)
|
|
389
|
+
);
|
|
326
390
|
} else {
|
|
327
391
|
const midY = (sc.y + tc.y) / 2;
|
|
328
392
|
const m1: Pt = { x: sc.x, y: midY };
|
|
329
393
|
const m2: Pt = { x: tc.x, y: midY };
|
|
330
|
-
return
|
|
331
|
-
|
|
332
|
-
|
|
394
|
+
return (
|
|
395
|
+
segmentIntersectsRect(sc, m1, rect) ||
|
|
396
|
+
segmentIntersectsRect(m1, m2, rect) ||
|
|
397
|
+
segmentIntersectsRect(m2, tc, rect)
|
|
398
|
+
);
|
|
333
399
|
}
|
|
334
400
|
}
|
|
335
401
|
|
|
@@ -348,7 +414,7 @@ function edgeWaypoints(
|
|
|
348
414
|
direction: 'LR' | 'TB',
|
|
349
415
|
margin = 30,
|
|
350
416
|
srcExitPt?: Pt, // port-ordered exit point on source border
|
|
351
|
-
tgtEnterPt?: Pt
|
|
417
|
+
tgtEnterPt?: Pt // port-ordered enter point on target border
|
|
352
418
|
): Pt[] {
|
|
353
419
|
const sc: Pt = { x: source.x, y: source.y };
|
|
354
420
|
const tc: Pt = { x: target.x, y: target.y };
|
|
@@ -370,13 +436,20 @@ function edgeWaypoints(
|
|
|
370
436
|
const nLeft = n.x - n.width / 2;
|
|
371
437
|
const nRight = n.x + n.width / 2;
|
|
372
438
|
if (nRight < tc.x - margin || nLeft > sc.x + margin) continue;
|
|
373
|
-
xBandObs.push({
|
|
439
|
+
xBandObs.push({
|
|
440
|
+
x: nLeft,
|
|
441
|
+
y: n.y - n.height / 2,
|
|
442
|
+
width: n.width,
|
|
443
|
+
height: n.height,
|
|
444
|
+
});
|
|
374
445
|
}
|
|
375
|
-
const midY
|
|
376
|
-
const routeY =
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
446
|
+
const midY = (sc.y + tc.y) / 2;
|
|
447
|
+
const routeY =
|
|
448
|
+
xBandObs.length > 0 ? findRoutingLane(xBandObs, midY, margin) : midY;
|
|
449
|
+
const exitBorder: Pt =
|
|
450
|
+
srcExitPt ?? nodeBorderPoint(source, { x: sc.x, y: routeY });
|
|
451
|
+
const exitPt: Pt = { x: exitBorder.x, y: routeY };
|
|
452
|
+
const enterPt: Pt = { x: tc.x, y: routeY };
|
|
380
453
|
const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
|
|
381
454
|
return srcExitPt
|
|
382
455
|
? [srcExitPt, exitPt, enterPt, tp]
|
|
@@ -393,14 +466,25 @@ function edgeWaypoints(
|
|
|
393
466
|
const nTop = n.y - n.height / 2;
|
|
394
467
|
const nBot = n.y + n.height / 2;
|
|
395
468
|
if (nBot < tc.y - margin || nTop > sc.y + margin) continue;
|
|
396
|
-
yBandObs.push({
|
|
469
|
+
yBandObs.push({
|
|
470
|
+
x: n.x - n.width / 2,
|
|
471
|
+
y: nTop,
|
|
472
|
+
width: n.width,
|
|
473
|
+
height: n.height,
|
|
474
|
+
});
|
|
397
475
|
}
|
|
398
476
|
// Rotate axes so findRoutingLane (which works in Y) resolves an X lane
|
|
399
|
-
const rotated = yBandObs.map((r) => ({
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
477
|
+
const rotated = yBandObs.map((r) => ({
|
|
478
|
+
x: r.y,
|
|
479
|
+
y: r.x,
|
|
480
|
+
width: r.height,
|
|
481
|
+
height: r.width,
|
|
482
|
+
}));
|
|
483
|
+
const midX = (sc.x + tc.x) / 2;
|
|
484
|
+
const routeX =
|
|
485
|
+
rotated.length > 0 ? findRoutingLane(rotated, midX, margin) : midX;
|
|
486
|
+
const exitPt: Pt = srcExitPt ?? { x: routeX, y: sc.y };
|
|
487
|
+
const enterPt: Pt = { x: routeX, y: tc.y };
|
|
404
488
|
return [
|
|
405
489
|
srcExitPt ?? nodeBorderPoint(source, exitPt),
|
|
406
490
|
exitPt,
|
|
@@ -411,7 +495,8 @@ function edgeWaypoints(
|
|
|
411
495
|
}
|
|
412
496
|
|
|
413
497
|
// ── Forward edge: obstacle avoidance (groups + individual nodes) ─────────
|
|
414
|
-
const blocking: { x: number; y: number; width: number; height: number }[] =
|
|
498
|
+
const blocking: { x: number; y: number; width: number; height: number }[] =
|
|
499
|
+
[];
|
|
415
500
|
const blockingGroupIds = new Set<string>();
|
|
416
501
|
|
|
417
502
|
// Use actual path endpoints (port-ordered) for more accurate blocking detection.
|
|
@@ -433,10 +518,19 @@ function edgeWaypoints(
|
|
|
433
518
|
for (const n of nodes) {
|
|
434
519
|
if (n.id === source.id || n.id === target.id) continue;
|
|
435
520
|
// Skip nodes in source/target groups (routing around the group handles them)
|
|
436
|
-
if (
|
|
521
|
+
if (
|
|
522
|
+
n.groupId &&
|
|
523
|
+
(n.groupId === source.groupId || n.groupId === target.groupId)
|
|
524
|
+
)
|
|
525
|
+
continue;
|
|
437
526
|
// Skip nodes inside a group whose bounding box is already blocking
|
|
438
527
|
if (n.groupId && blockingGroupIds.has(n.groupId)) continue;
|
|
439
|
-
const nodeRect: Rect = {
|
|
528
|
+
const nodeRect: Rect = {
|
|
529
|
+
x: n.x - n.width / 2,
|
|
530
|
+
y: n.y - n.height / 2,
|
|
531
|
+
width: n.width,
|
|
532
|
+
height: n.height,
|
|
533
|
+
};
|
|
440
534
|
if (curveIntersectsRect(pathSrc, pathTgt, nodeRect, direction)) {
|
|
441
535
|
blocking.push(nodeRect);
|
|
442
536
|
}
|
|
@@ -449,17 +543,19 @@ function edgeWaypoints(
|
|
|
449
543
|
return [sp, tp];
|
|
450
544
|
}
|
|
451
545
|
|
|
452
|
-
const obsLeft
|
|
546
|
+
const obsLeft = Math.min(...blocking.map((o) => o.x));
|
|
453
547
|
const obsRight = Math.max(...blocking.map((o) => o.x + o.width));
|
|
454
548
|
|
|
455
549
|
const routeY = findRoutingLane(blocking, tc.y, margin);
|
|
456
550
|
|
|
457
551
|
// Clamp exit/enter X to [sc.x, tc.x] for LR so the path never reverses
|
|
458
552
|
// direction when an obstacle's bounding box extends past source or target.
|
|
459
|
-
const exitX
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
|
|
553
|
+
const exitX =
|
|
554
|
+
direction === 'LR' ? Math.max(sc.x, obsLeft - margin) : obsLeft - margin;
|
|
555
|
+
const enterX =
|
|
556
|
+
direction === 'LR' ? Math.min(tc.x, obsRight + margin) : obsRight + margin;
|
|
557
|
+
const exitPt: Pt = { x: exitX, y: routeY };
|
|
558
|
+
const enterPt: Pt = { x: enterX, y: routeY };
|
|
463
559
|
|
|
464
560
|
const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
|
|
465
561
|
|
|
@@ -473,7 +569,7 @@ function edgeWaypoints(
|
|
|
473
569
|
/** Compute the point on a node's border closest to an external target point. */
|
|
474
570
|
function nodeBorderPoint(
|
|
475
571
|
node: InfraLayoutNode,
|
|
476
|
-
target: { x: number; y: number }
|
|
572
|
+
target: { x: number; y: number }
|
|
477
573
|
): { x: number; y: number } {
|
|
478
574
|
const hw = node.width / 2;
|
|
479
575
|
const hh = node.height / 2;
|
|
@@ -500,7 +596,9 @@ function flowDuration(rps: number, maxRps: number): number {
|
|
|
500
596
|
function particleCount(rps: number, maxRps: number): number {
|
|
501
597
|
if (maxRps <= 0) return PARTICLE_COUNT_MIN;
|
|
502
598
|
const t = Math.min(rps / maxRps, 1);
|
|
503
|
-
return Math.round(
|
|
599
|
+
return Math.round(
|
|
600
|
+
PARTICLE_COUNT_MIN + t * (PARTICLE_COUNT_MAX - PARTICLE_COUNT_MIN)
|
|
601
|
+
);
|
|
504
602
|
}
|
|
505
603
|
|
|
506
604
|
/** Determine if a node is in warning state (>70% capacity but not overloaded). */
|
|
@@ -532,18 +630,18 @@ const PROP_DISPLAY: Record<string, string> = {
|
|
|
532
630
|
'firewall-block': 'firewall block',
|
|
533
631
|
'ratelimit-rps': 'rate limit RPS',
|
|
534
632
|
'latency-ms': 'latency',
|
|
535
|
-
|
|
536
|
-
|
|
633
|
+
uptime: 'uptime',
|
|
634
|
+
instances: 'instances',
|
|
537
635
|
'max-rps': 'max RPS',
|
|
538
636
|
'cb-error-threshold': 'CB error threshold',
|
|
539
637
|
'cb-latency-threshold-ms': 'CB latency threshold',
|
|
540
|
-
|
|
638
|
+
concurrency: 'concurrency',
|
|
541
639
|
'duration-ms': 'duration',
|
|
542
640
|
'cold-start-ms': 'cold start',
|
|
543
|
-
|
|
641
|
+
buffer: 'buffer',
|
|
544
642
|
'drain-rate': 'drain rate',
|
|
545
643
|
'retention-hours': 'retention',
|
|
546
|
-
|
|
644
|
+
partitions: 'partitions',
|
|
547
645
|
};
|
|
548
646
|
|
|
549
647
|
const DESC_MAX_CHARS = 120;
|
|
@@ -558,10 +656,20 @@ function truncateDesc(text: string): string {
|
|
|
558
656
|
const RPS_FORMAT_KEYS = new Set(['max-rps', 'ratelimit-rps']);
|
|
559
657
|
|
|
560
658
|
/** Keys whose values are milliseconds and should show the "ms" suffix. */
|
|
561
|
-
const MS_FORMAT_KEYS = new Set([
|
|
659
|
+
const MS_FORMAT_KEYS = new Set([
|
|
660
|
+
'latency-ms',
|
|
661
|
+
'cb-latency-threshold-ms',
|
|
662
|
+
'duration-ms',
|
|
663
|
+
'cold-start-ms',
|
|
664
|
+
]);
|
|
562
665
|
|
|
563
666
|
/** Keys whose values are percentages and should show the "%" suffix. */
|
|
564
|
-
const PCT_FORMAT_KEYS = new Set([
|
|
667
|
+
const PCT_FORMAT_KEYS = new Set([
|
|
668
|
+
'cache-hit',
|
|
669
|
+
'firewall-block',
|
|
670
|
+
'uptime',
|
|
671
|
+
'cb-error-threshold',
|
|
672
|
+
]);
|
|
565
673
|
|
|
566
674
|
/** Compute SLO color for a p90 latency value against the configured threshold.
|
|
567
675
|
* Callers must guard slo.latencyP90 != null before calling. */
|
|
@@ -569,11 +677,19 @@ function sloLatencyColor(p90: number, slo: NodeSlo): string {
|
|
|
569
677
|
const t = slo.latencyP90 ?? 0;
|
|
570
678
|
if (t === 0) return COLOR_HEALTHY; // no meaningful threshold — treat as healthy
|
|
571
679
|
const m = slo.warningMargin;
|
|
572
|
-
return p90 > t
|
|
680
|
+
return p90 > t
|
|
681
|
+
? COLOR_OVERLOADED
|
|
682
|
+
: p90 > t * (1 - m)
|
|
683
|
+
? COLOR_WARNING
|
|
684
|
+
: COLOR_HEALTHY;
|
|
573
685
|
}
|
|
574
686
|
|
|
575
687
|
/** Computed metric rows (latency percentiles, uptime, availability, CB state) shown after declared props. */
|
|
576
|
-
function getComputedRows(
|
|
688
|
+
function getComputedRows(
|
|
689
|
+
node: InfraLayoutNode,
|
|
690
|
+
expanded: boolean,
|
|
691
|
+
slo?: NodeSlo | null
|
|
692
|
+
): ComputedRow[] {
|
|
577
693
|
const rows: ComputedRow[] = [];
|
|
578
694
|
|
|
579
695
|
// Serverless instances: demand vs concurrency limit
|
|
@@ -581,10 +697,12 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
|
|
|
581
697
|
const concurrency = getNodeNumProp(node, 'concurrency', 0);
|
|
582
698
|
const demand = node.computedConcurrentInvocations;
|
|
583
699
|
const ratio = concurrency > 0 ? demand / concurrency : 0;
|
|
584
|
-
const color =
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
700
|
+
const color =
|
|
701
|
+
ratio > 1 ? COLOR_OVERLOADED : ratio > 0.7 ? COLOR_WARNING : undefined;
|
|
702
|
+
const value =
|
|
703
|
+
concurrency > 0
|
|
704
|
+
? `${formatCount(demand)} / ${formatCount(concurrency)}`
|
|
705
|
+
: `${formatCount(demand)}`;
|
|
588
706
|
rows.push({ key: 'instances', value, color, inverted: color != null });
|
|
589
707
|
}
|
|
590
708
|
|
|
@@ -594,10 +712,16 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
|
|
|
594
712
|
rows.push({ key: 'p50', value: formatMsShort(p.p50) });
|
|
595
713
|
if (slo?.latencyP90 != null) {
|
|
596
714
|
const color = sloLatencyColor(p.p90, slo);
|
|
597
|
-
const p90Value =
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
715
|
+
const p90Value =
|
|
716
|
+
color !== COLOR_HEALTHY
|
|
717
|
+
? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
|
|
718
|
+
: formatMsShort(p.p90);
|
|
719
|
+
rows.push({
|
|
720
|
+
key: 'p90',
|
|
721
|
+
value: p90Value,
|
|
722
|
+
color,
|
|
723
|
+
inverted: color !== COLOR_HEALTHY,
|
|
724
|
+
});
|
|
601
725
|
} else {
|
|
602
726
|
rows.push({ key: 'p90', value: formatMsShort(p.p90) });
|
|
603
727
|
}
|
|
@@ -606,10 +730,16 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
|
|
|
606
730
|
// Collapsed: show p90 (with SLO color if configured) instead of p99
|
|
607
731
|
if (slo?.latencyP90 != null) {
|
|
608
732
|
const color = sloLatencyColor(p.p90, slo);
|
|
609
|
-
const p90Value =
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
733
|
+
const p90Value =
|
|
734
|
+
color !== COLOR_HEALTHY
|
|
735
|
+
? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
|
|
736
|
+
: formatMsShort(p.p90);
|
|
737
|
+
rows.push({
|
|
738
|
+
key: 'p90',
|
|
739
|
+
value: p90Value,
|
|
740
|
+
color,
|
|
741
|
+
inverted: color !== COLOR_HEALTHY,
|
|
742
|
+
});
|
|
613
743
|
} else {
|
|
614
744
|
rows.push({ key: 'p90', value: formatMsShort(p.p90) });
|
|
615
745
|
}
|
|
@@ -623,7 +753,10 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
|
|
|
623
753
|
const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
|
|
624
754
|
const differs = Math.abs(node.computedUptime - declaredVal) > 0.000001;
|
|
625
755
|
if (differs || node.isEdge) {
|
|
626
|
-
rows.push({
|
|
756
|
+
rows.push({
|
|
757
|
+
key: 'eff. uptime',
|
|
758
|
+
value: formatUptimeShort(node.computedUptime),
|
|
759
|
+
});
|
|
627
760
|
}
|
|
628
761
|
}
|
|
629
762
|
if (node.computedAvailability < 1) {
|
|
@@ -632,27 +765,45 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
|
|
|
632
765
|
const t = slo.availThreshold;
|
|
633
766
|
const m = slo.warningMargin;
|
|
634
767
|
if (node.computedAvailability < t) color = COLOR_OVERLOADED;
|
|
635
|
-
else if (node.computedAvailability < Math.min(1, t + m))
|
|
768
|
+
else if (node.computedAvailability < Math.min(1, t + m))
|
|
769
|
+
color = COLOR_WARNING;
|
|
636
770
|
else color = COLOR_HEALTHY;
|
|
637
771
|
} else {
|
|
638
|
-
color =
|
|
639
|
-
|
|
640
|
-
|
|
772
|
+
color =
|
|
773
|
+
node.computedAvailability < 0.95
|
|
774
|
+
? COLOR_OVERLOADED
|
|
775
|
+
: node.computedAvailability < 0.99
|
|
776
|
+
? COLOR_WARNING
|
|
777
|
+
: undefined;
|
|
641
778
|
}
|
|
642
|
-
rows.push({
|
|
779
|
+
rows.push({
|
|
780
|
+
key: 'availability',
|
|
781
|
+
value: formatUptimeShort(node.computedAvailability),
|
|
782
|
+
color,
|
|
783
|
+
inverted: color != null && color !== COLOR_HEALTHY,
|
|
784
|
+
});
|
|
643
785
|
}
|
|
644
786
|
// Circuit breaker state — show when a CB is configured and open
|
|
645
787
|
if (node.computedCbState === 'open') {
|
|
646
|
-
rows.push({
|
|
788
|
+
rows.push({
|
|
789
|
+
key: 'CB',
|
|
790
|
+
value: 'OPEN',
|
|
791
|
+
color: COLOR_OVERLOADED,
|
|
792
|
+
inverted: true,
|
|
793
|
+
});
|
|
647
794
|
}
|
|
648
795
|
// Queue computed rows: lag and overflow
|
|
649
796
|
if (node.queueMetrics) {
|
|
650
797
|
const { fillRate, timeToOverflow } = node.queueMetrics;
|
|
651
798
|
if (fillRate > 0) {
|
|
652
|
-
rows.push({
|
|
799
|
+
rows.push({
|
|
800
|
+
key: 'lag',
|
|
801
|
+
value: `${formatCount(Math.round(fillRate))} msg/s`,
|
|
802
|
+
});
|
|
653
803
|
}
|
|
654
804
|
if (fillRate > 0 && timeToOverflow < Infinity) {
|
|
655
|
-
const overflowColor =
|
|
805
|
+
const overflowColor =
|
|
806
|
+
timeToOverflow < 60 ? COLOR_OVERLOADED : COLOR_WARNING;
|
|
656
807
|
rows.push({
|
|
657
808
|
key: 'overflow',
|
|
658
809
|
value: `~${Math.round(timeToOverflow)}s`,
|
|
@@ -665,7 +816,8 @@ function getComputedRows(node: InfraLayoutNode, expanded: boolean, slo?: NodeSlo
|
|
|
665
816
|
}
|
|
666
817
|
|
|
667
818
|
function formatCount(n: number): string {
|
|
668
|
-
if (n >= 1000000)
|
|
819
|
+
if (n >= 1000000)
|
|
820
|
+
return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
|
|
669
821
|
if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
|
|
670
822
|
return String(n);
|
|
671
823
|
}
|
|
@@ -684,7 +836,11 @@ function formatUptimeShort(fraction: number): string {
|
|
|
684
836
|
}
|
|
685
837
|
|
|
686
838
|
/** Properties shown as key-value rows inside the node card. */
|
|
687
|
-
function getDisplayProps(
|
|
839
|
+
function getDisplayProps(
|
|
840
|
+
node: InfraLayoutNode,
|
|
841
|
+
expanded: boolean,
|
|
842
|
+
diagramOptions?: Record<string, string>
|
|
843
|
+
): { key: string; displayKey: string; value: string }[] {
|
|
688
844
|
if (node.isEdge) return [];
|
|
689
845
|
const rows: { key: string; displayKey: string; value: string }[] = [];
|
|
690
846
|
for (const p of node.properties) {
|
|
@@ -697,23 +853,53 @@ function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOption
|
|
|
697
853
|
if (p.key === 'concurrency' && !expanded) continue;
|
|
698
854
|
// Format values with appropriate units
|
|
699
855
|
if (RPS_FORMAT_KEYS.has(p.key)) {
|
|
700
|
-
const num =
|
|
701
|
-
|
|
856
|
+
const num =
|
|
857
|
+
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
858
|
+
rows.push({
|
|
859
|
+
key: p.key,
|
|
860
|
+
displayKey,
|
|
861
|
+
value: isNaN(num) ? String(p.value) : formatRpsShort(num),
|
|
862
|
+
});
|
|
702
863
|
} else if (MS_FORMAT_KEYS.has(p.key)) {
|
|
703
|
-
const num =
|
|
704
|
-
|
|
864
|
+
const num =
|
|
865
|
+
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
866
|
+
rows.push({
|
|
867
|
+
key: p.key,
|
|
868
|
+
displayKey,
|
|
869
|
+
value: isNaN(num) ? String(p.value) : formatMsShort(num),
|
|
870
|
+
});
|
|
705
871
|
} else if (PCT_FORMAT_KEYS.has(p.key)) {
|
|
706
|
-
const num =
|
|
707
|
-
|
|
872
|
+
const num =
|
|
873
|
+
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
874
|
+
rows.push({
|
|
875
|
+
key: p.key,
|
|
876
|
+
displayKey,
|
|
877
|
+
value: isNaN(num) ? String(p.value) : `${num}%`,
|
|
878
|
+
});
|
|
708
879
|
} else if (p.key === 'buffer') {
|
|
709
|
-
const num =
|
|
710
|
-
|
|
880
|
+
const num =
|
|
881
|
+
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
882
|
+
rows.push({
|
|
883
|
+
key: p.key,
|
|
884
|
+
displayKey,
|
|
885
|
+
value: isNaN(num) ? String(p.value) : formatCount(num),
|
|
886
|
+
});
|
|
711
887
|
} else if (p.key === 'drain-rate') {
|
|
712
|
-
const num =
|
|
713
|
-
|
|
888
|
+
const num =
|
|
889
|
+
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
890
|
+
rows.push({
|
|
891
|
+
key: p.key,
|
|
892
|
+
displayKey,
|
|
893
|
+
value: isNaN(num) ? String(p.value) : `${formatRpsShort(num)}/s`,
|
|
894
|
+
});
|
|
714
895
|
} else if (p.key === 'retention-hours') {
|
|
715
|
-
const num =
|
|
716
|
-
|
|
896
|
+
const num =
|
|
897
|
+
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
898
|
+
rows.push({
|
|
899
|
+
key: p.key,
|
|
900
|
+
displayKey,
|
|
901
|
+
value: isNaN(num) ? String(p.value) : `${num}h`,
|
|
902
|
+
});
|
|
717
903
|
} else {
|
|
718
904
|
rows.push({ key: p.key, displayKey, value: String(p.value) });
|
|
719
905
|
}
|
|
@@ -722,20 +908,31 @@ function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOption
|
|
|
722
908
|
if (diagramOptions) {
|
|
723
909
|
const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
|
|
724
910
|
const hasUptime = node.properties.some((p) => p.key === 'uptime');
|
|
725
|
-
const isServerlessNode = node.properties.some(
|
|
726
|
-
|
|
727
|
-
|
|
911
|
+
const isServerlessNode = node.properties.some(
|
|
912
|
+
(p) => p.key === 'concurrency'
|
|
913
|
+
);
|
|
914
|
+
const defaultLatency =
|
|
915
|
+
parseFloat(diagramOptions['default-latency-ms'] ?? '') || 0;
|
|
916
|
+
const defaultUptime =
|
|
917
|
+
parseFloat(diagramOptions['default-uptime'] ?? '') || 0;
|
|
728
918
|
if (!hasLatency && !isServerlessNode && defaultLatency > 0) {
|
|
729
|
-
rows.push({
|
|
919
|
+
rows.push({
|
|
920
|
+
key: 'latency-ms',
|
|
921
|
+
displayKey: 'latency',
|
|
922
|
+
value: formatMsShort(defaultLatency),
|
|
923
|
+
});
|
|
730
924
|
}
|
|
731
925
|
if (!hasUptime && defaultUptime > 0 && defaultUptime < 100) {
|
|
732
|
-
rows.push({
|
|
926
|
+
rows.push({
|
|
927
|
+
key: 'uptime',
|
|
928
|
+
displayKey: 'uptime',
|
|
929
|
+
value: `${defaultUptime}%`,
|
|
930
|
+
});
|
|
733
931
|
}
|
|
734
932
|
}
|
|
735
933
|
return rows;
|
|
736
934
|
}
|
|
737
935
|
|
|
738
|
-
|
|
739
936
|
/** RPS value without "rps" suffix — for key-value rows where the key already says "RPS". */
|
|
740
937
|
function formatRpsShort(rps: number): string {
|
|
741
938
|
if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k`;
|
|
@@ -746,7 +943,7 @@ function formatRpsShort(rps: number): string {
|
|
|
746
943
|
* Returns 'overloaded' (red), 'warning' (yellow), 'healthy' (green), or 'normal'. */
|
|
747
944
|
function worstNodeSeverity(
|
|
748
945
|
node: InfraLayoutNode,
|
|
749
|
-
slo?: NodeSlo | null
|
|
946
|
+
slo?: NodeSlo | null
|
|
750
947
|
): 'overloaded' | 'warning' | 'healthy' | 'normal' {
|
|
751
948
|
let worst: 'overloaded' | 'warning' | 'normal' = 'normal';
|
|
752
949
|
const upgrade = (s: 'overloaded' | 'warning') => {
|
|
@@ -810,10 +1007,14 @@ function worstNodeSeverity(
|
|
|
810
1007
|
|
|
811
1008
|
// Healthy: SLO declared AND all checks are in the green zone
|
|
812
1009
|
if (worst === 'normal' && slo != null) {
|
|
813
|
-
const availGreen =
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1010
|
+
const availGreen =
|
|
1011
|
+
slo.availThreshold == null ||
|
|
1012
|
+
node.computedAvailability >=
|
|
1013
|
+
Math.min(1, slo.availThreshold + slo.warningMargin);
|
|
1014
|
+
const latencyGreen =
|
|
1015
|
+
slo.latencyP90 == null ||
|
|
1016
|
+
node.computedLatencyPercentiles.p90 <=
|
|
1017
|
+
slo.latencyP90 * (1 - slo.warningMargin);
|
|
817
1018
|
if (availGreen && latencyGreen) return 'healthy';
|
|
818
1019
|
}
|
|
819
1020
|
|
|
@@ -824,7 +1025,7 @@ function nodeColor(
|
|
|
824
1025
|
_node: InfraLayoutNode,
|
|
825
1026
|
palette: PaletteColors,
|
|
826
1027
|
isDark: boolean,
|
|
827
|
-
severity: ReturnType<typeof worstNodeSeverity
|
|
1028
|
+
severity: ReturnType<typeof worstNodeSeverity>
|
|
828
1029
|
): {
|
|
829
1030
|
fill: string;
|
|
830
1031
|
stroke: string;
|
|
@@ -852,8 +1053,12 @@ function nodeColor(
|
|
|
852
1053
|
};
|
|
853
1054
|
}
|
|
854
1055
|
return {
|
|
855
|
-
fill: isDark
|
|
856
|
-
|
|
1056
|
+
fill: isDark
|
|
1057
|
+
? mix(palette.bg, palette.text, 90)
|
|
1058
|
+
: mix(palette.bg, palette.text, 95),
|
|
1059
|
+
stroke: isDark
|
|
1060
|
+
? mix(palette.text, palette.bg, 60)
|
|
1061
|
+
: mix(palette.text, palette.bg, 40),
|
|
857
1062
|
textFill: palette.text,
|
|
858
1063
|
};
|
|
859
1064
|
}
|
|
@@ -874,10 +1079,11 @@ function renderGroups(
|
|
|
874
1079
|
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
875
1080
|
groups: InfraLayoutGroup[],
|
|
876
1081
|
palette: PaletteColors,
|
|
877
|
-
|
|
1082
|
+
_isDark: boolean
|
|
878
1083
|
) {
|
|
879
1084
|
for (const group of groups) {
|
|
880
|
-
const g = svg
|
|
1085
|
+
const g = svg
|
|
1086
|
+
.append('g')
|
|
881
1087
|
.attr('class', 'infra-group')
|
|
882
1088
|
.attr('data-line-number', group.lineNumber)
|
|
883
1089
|
.attr('data-node-toggle', group.id)
|
|
@@ -909,8 +1115,12 @@ function renderGroups(
|
|
|
909
1115
|
.text(group.label);
|
|
910
1116
|
|
|
911
1117
|
// Group instances badge (top-right, like node instance badges)
|
|
912
|
-
const gi =
|
|
913
|
-
typeof group.instances === '
|
|
1118
|
+
const gi =
|
|
1119
|
+
typeof group.instances === 'number'
|
|
1120
|
+
? group.instances
|
|
1121
|
+
: typeof group.instances === 'string'
|
|
1122
|
+
? parseInt(String(group.instances), 10) || 0
|
|
1123
|
+
: 0;
|
|
914
1124
|
if (gi > 1) {
|
|
915
1125
|
g.append('text')
|
|
916
1126
|
.attr('x', group.x + group.width - 8)
|
|
@@ -933,7 +1143,7 @@ function renderEdgePaths(
|
|
|
933
1143
|
isDark: boolean,
|
|
934
1144
|
animate: boolean,
|
|
935
1145
|
direction: 'LR' | 'TB',
|
|
936
|
-
speedMultiplier: number = 1
|
|
1146
|
+
speedMultiplier: number = 1
|
|
937
1147
|
) {
|
|
938
1148
|
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
939
1149
|
const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
|
|
@@ -950,15 +1160,23 @@ function renderEdgePaths(
|
|
|
950
1160
|
if (!sourceNode || !targetNode) continue;
|
|
951
1161
|
const key = `${edge.sourceId}:${edge.targetId}`;
|
|
952
1162
|
const pts = edgeWaypoints(
|
|
953
|
-
sourceNode,
|
|
954
|
-
|
|
1163
|
+
sourceNode,
|
|
1164
|
+
targetNode,
|
|
1165
|
+
groups,
|
|
1166
|
+
nodes,
|
|
1167
|
+
direction,
|
|
1168
|
+
30,
|
|
1169
|
+
srcPts.get(key),
|
|
1170
|
+
tgtPts.get(key)
|
|
955
1171
|
);
|
|
956
1172
|
const pathD = buildPathD(pts, direction);
|
|
957
|
-
const edgeG = svg
|
|
1173
|
+
const edgeG = svg
|
|
1174
|
+
.append('g')
|
|
958
1175
|
.attr('class', 'infra-edge')
|
|
959
1176
|
.attr('data-line-number', edge.lineNumber);
|
|
960
1177
|
|
|
961
|
-
const edgePath = edgeG
|
|
1178
|
+
const edgePath = edgeG
|
|
1179
|
+
.append('path')
|
|
962
1180
|
.attr('d', pathD)
|
|
963
1181
|
.attr('fill', 'none')
|
|
964
1182
|
.attr('stroke', color)
|
|
@@ -978,13 +1196,15 @@ function renderEdgePaths(
|
|
|
978
1196
|
|
|
979
1197
|
for (let i = 0; i < count; i++) {
|
|
980
1198
|
const delay = (dur / count) * i;
|
|
981
|
-
const circle = edgeG
|
|
1199
|
+
const circle = edgeG
|
|
1200
|
+
.append('circle')
|
|
982
1201
|
.attr('r', PARTICLE_R)
|
|
983
1202
|
.attr('fill', particleColor)
|
|
984
1203
|
.attr('opacity', 0.85);
|
|
985
1204
|
|
|
986
1205
|
// Use SMIL <animateMotion> for path-following
|
|
987
|
-
circle
|
|
1206
|
+
circle
|
|
1207
|
+
.append('animateMotion')
|
|
988
1208
|
.attr('dur', `${dur}s`)
|
|
989
1209
|
.attr('repeatCount', 'indefinite')
|
|
990
1210
|
.attr('begin', `${delay}s`)
|
|
@@ -1002,7 +1222,7 @@ function renderEdgeLabels(
|
|
|
1002
1222
|
palette: PaletteColors,
|
|
1003
1223
|
isDark: boolean,
|
|
1004
1224
|
animate: boolean,
|
|
1005
|
-
direction: 'LR' | 'TB'
|
|
1225
|
+
direction: 'LR' | 'TB'
|
|
1006
1226
|
) {
|
|
1007
1227
|
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
1008
1228
|
const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
|
|
@@ -1016,15 +1236,20 @@ function renderEdgeLabels(
|
|
|
1016
1236
|
|
|
1017
1237
|
const key = `${edge.sourceId}:${edge.targetId}`;
|
|
1018
1238
|
const wps = edgeWaypoints(
|
|
1019
|
-
sourceNode,
|
|
1020
|
-
|
|
1239
|
+
sourceNode,
|
|
1240
|
+
targetNode,
|
|
1241
|
+
groups,
|
|
1242
|
+
nodes,
|
|
1243
|
+
direction,
|
|
1244
|
+
30,
|
|
1245
|
+
srcPts.get(key),
|
|
1246
|
+
tgtPts.get(key)
|
|
1021
1247
|
);
|
|
1022
1248
|
// Label midpoint: middle waypoint of the routed path
|
|
1023
1249
|
const midPt = wps[Math.floor(wps.length / 2)];
|
|
1024
1250
|
const labelText = edge.label;
|
|
1025
1251
|
|
|
1026
|
-
const g = svg.append('g')
|
|
1027
|
-
.attr('class', animate ? 'infra-edge-label' : '');
|
|
1252
|
+
const g = svg.append('g').attr('class', animate ? 'infra-edge-label' : '');
|
|
1028
1253
|
|
|
1029
1254
|
// Background rect for readability
|
|
1030
1255
|
const textWidth = labelText.length * 6.5 + 8;
|
|
@@ -1063,14 +1288,18 @@ function resolveActiveTagStroke(
|
|
|
1063
1288
|
node: InfraLayoutNode,
|
|
1064
1289
|
activeGroup: string,
|
|
1065
1290
|
tagGroups: InfraTagGroup[],
|
|
1066
|
-
palette: PaletteColors
|
|
1291
|
+
palette: PaletteColors
|
|
1067
1292
|
): string | null {
|
|
1068
|
-
const tg = tagGroups.find(
|
|
1293
|
+
const tg = tagGroups.find(
|
|
1294
|
+
(t) => t.name.toLowerCase() === activeGroup.toLowerCase()
|
|
1295
|
+
);
|
|
1069
1296
|
if (!tg) return null;
|
|
1070
1297
|
const tagKey = (tg.alias ?? tg.name).toLowerCase();
|
|
1071
1298
|
const tagVal = node.tags[tagKey];
|
|
1072
1299
|
if (!tagVal) return null;
|
|
1073
|
-
const tv = tg.values.find(
|
|
1300
|
+
const tv = tg.values.find(
|
|
1301
|
+
(v) => v.name.toLowerCase() === tagVal.toLowerCase()
|
|
1302
|
+
);
|
|
1074
1303
|
if (!tv?.color) return null;
|
|
1075
1304
|
return resolveColor(tv.color, palette);
|
|
1076
1305
|
}
|
|
@@ -1087,18 +1316,28 @@ function renderNodes(
|
|
|
1087
1316
|
collapsedNodes?: Set<string> | null,
|
|
1088
1317
|
tagGroups?: InfraTagGroup[],
|
|
1089
1318
|
fanoutSourceIds?: Set<string>,
|
|
1090
|
-
scaledGroupIds?: Set<string
|
|
1319
|
+
scaledGroupIds?: Set<string>
|
|
1091
1320
|
) {
|
|
1092
1321
|
const mutedColor = palette.textMuted;
|
|
1093
1322
|
|
|
1094
1323
|
for (const node of nodes) {
|
|
1095
|
-
const slo =
|
|
1324
|
+
const slo =
|
|
1325
|
+
!node.isEdge && diagramOptions
|
|
1326
|
+
? resolveNodeSlo(node, diagramOptions)
|
|
1327
|
+
: null;
|
|
1096
1328
|
const severity = worstNodeSeverity(node, slo);
|
|
1097
|
-
|
|
1329
|
+
const nodeColors = nodeColor(node, palette, isDark, severity);
|
|
1330
|
+
const textFill = nodeColors.textFill;
|
|
1331
|
+
let { fill, stroke } = nodeColors;
|
|
1098
1332
|
|
|
1099
1333
|
// When a tag legend is active, override border color with tag color
|
|
1100
1334
|
if (activeGroup && tagGroups && !node.isEdge) {
|
|
1101
|
-
const tagStroke = resolveActiveTagStroke(
|
|
1335
|
+
const tagStroke = resolveActiveTagStroke(
|
|
1336
|
+
node,
|
|
1337
|
+
activeGroup,
|
|
1338
|
+
tagGroups,
|
|
1339
|
+
palette
|
|
1340
|
+
);
|
|
1102
1341
|
if (tagStroke) {
|
|
1103
1342
|
stroke = tagStroke;
|
|
1104
1343
|
fill = mix(palette.bg, tagStroke, isDark ? 88 : 94);
|
|
@@ -1112,7 +1351,8 @@ function renderNodes(
|
|
|
1112
1351
|
else if (severity === 'overloaded') cls += ' infra-node-overload';
|
|
1113
1352
|
else if (severity === 'warning') cls += ' infra-node-warning';
|
|
1114
1353
|
}
|
|
1115
|
-
const g = svg
|
|
1354
|
+
const g = svg
|
|
1355
|
+
.append('g')
|
|
1116
1356
|
.attr('class', cls)
|
|
1117
1357
|
.attr('data-line-number', node.lineNumber)
|
|
1118
1358
|
.attr('data-infra-node', node.id)
|
|
@@ -1133,7 +1373,10 @@ function renderNodes(
|
|
|
1133
1373
|
if (!node.isEdge) {
|
|
1134
1374
|
const roles = inferRoles(node.properties);
|
|
1135
1375
|
for (const role of roles) {
|
|
1136
|
-
g.attr(
|
|
1376
|
+
g.attr(
|
|
1377
|
+
`data-role-${role.name.toLowerCase().replace(/\s+/g, '-')}`,
|
|
1378
|
+
'true'
|
|
1379
|
+
);
|
|
1137
1380
|
}
|
|
1138
1381
|
if (fanoutSourceIds?.has(node.id)) {
|
|
1139
1382
|
g.attr('data-role-fan-out', 'true');
|
|
@@ -1143,7 +1386,8 @@ function renderNodes(
|
|
|
1143
1386
|
const x = node.x - node.width / 2;
|
|
1144
1387
|
const y = node.y - node.height / 2;
|
|
1145
1388
|
const isCollapsedGroup = node.id.startsWith('[');
|
|
1146
|
-
const strokeWidth =
|
|
1389
|
+
const strokeWidth =
|
|
1390
|
+
severity !== 'normal' ? OVERLOAD_STROKE_WIDTH : NODE_STROKE_WIDTH;
|
|
1147
1391
|
|
|
1148
1392
|
// Node rect
|
|
1149
1393
|
g.append('rect')
|
|
@@ -1187,13 +1431,21 @@ function renderNodes(
|
|
|
1187
1431
|
const expanded = expandedNodeIds?.has(node.id) ?? false;
|
|
1188
1432
|
|
|
1189
1433
|
// Description subtitle — shown below label only when node is selected
|
|
1190
|
-
const descH =
|
|
1434
|
+
const descH =
|
|
1435
|
+
expanded && node.description && !node.isEdge ? META_LINE_HEIGHT : 0;
|
|
1191
1436
|
if (descH > 0 && node.description) {
|
|
1192
1437
|
const descTruncated = truncateDesc(node.description);
|
|
1193
1438
|
const isTruncated = descTruncated !== node.description;
|
|
1194
|
-
const textEl = g
|
|
1439
|
+
const textEl = g
|
|
1440
|
+
.append('text')
|
|
1195
1441
|
.attr('x', node.x)
|
|
1196
|
-
.attr(
|
|
1442
|
+
.attr(
|
|
1443
|
+
'y',
|
|
1444
|
+
y +
|
|
1445
|
+
NODE_HEADER_HEIGHT +
|
|
1446
|
+
META_LINE_HEIGHT / 2 +
|
|
1447
|
+
META_FONT_SIZE * 0.35
|
|
1448
|
+
)
|
|
1197
1449
|
.attr('text-anchor', 'middle')
|
|
1198
1450
|
.attr('font-family', FONT_FAMILY)
|
|
1199
1451
|
.attr('font-size', META_FONT_SIZE)
|
|
@@ -1203,9 +1455,15 @@ function renderNodes(
|
|
|
1203
1455
|
}
|
|
1204
1456
|
|
|
1205
1457
|
// Declared properties only shown when node is selected (expanded)
|
|
1206
|
-
const displayProps =
|
|
1458
|
+
const displayProps =
|
|
1459
|
+
!node.isEdge && expanded
|
|
1460
|
+
? getDisplayProps(node, expanded, diagramOptions)
|
|
1461
|
+
: [];
|
|
1207
1462
|
const computedRows = getComputedRows(node, expanded, slo);
|
|
1208
|
-
const hasContent =
|
|
1463
|
+
const hasContent =
|
|
1464
|
+
displayProps.length > 0 ||
|
|
1465
|
+
computedRows.length > 0 ||
|
|
1466
|
+
node.computedRps > 0;
|
|
1209
1467
|
|
|
1210
1468
|
if (hasContent) {
|
|
1211
1469
|
// Separator line between header and body
|
|
@@ -1229,13 +1487,18 @@ function renderNodes(
|
|
|
1229
1487
|
const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
|
|
1230
1488
|
const nodeConcurrency = getNodeNumProp(node, 'concurrency', 0);
|
|
1231
1489
|
const nodeDurationMs = getNodeNumProp(node, 'duration-ms', 100);
|
|
1232
|
-
const serverlessCap =
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1490
|
+
const serverlessCap =
|
|
1491
|
+
nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
|
|
1492
|
+
const effectiveCap =
|
|
1493
|
+
serverlessCap > 0
|
|
1494
|
+
? serverlessCap
|
|
1495
|
+
: nodeMaxRps > 0 && nodeRateLimit > 0
|
|
1496
|
+
? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
|
|
1497
|
+
: nodeMaxRps > 0
|
|
1498
|
+
? nodeMaxRps * node.computedInstances
|
|
1499
|
+
: nodeRateLimit > 0
|
|
1500
|
+
? nodeRateLimit
|
|
1501
|
+
: 0;
|
|
1239
1502
|
|
|
1240
1503
|
// --- Computed section: RPS + computed metrics ---
|
|
1241
1504
|
if (node.computedRps > 0) {
|
|
@@ -1251,25 +1514,41 @@ function renderNodes(
|
|
|
1251
1514
|
else if (preRl > nodeRateLimit * 0.8) rlSeverity = 'warning';
|
|
1252
1515
|
}
|
|
1253
1516
|
const rpsSeverity: 'overloaded' | 'warning' | 'normal' =
|
|
1254
|
-
node.overloaded
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1517
|
+
node.overloaded
|
|
1518
|
+
? 'overloaded'
|
|
1519
|
+
: rlSeverity === 'overloaded'
|
|
1520
|
+
? 'overloaded'
|
|
1521
|
+
: node.rateLimited
|
|
1522
|
+
? 'warning'
|
|
1523
|
+
: isWarning(node)
|
|
1524
|
+
? 'warning'
|
|
1525
|
+
: rlSeverity === 'warning'
|
|
1526
|
+
? 'warning'
|
|
1527
|
+
: 'normal';
|
|
1528
|
+
const rpsColor =
|
|
1529
|
+
rpsSeverity === 'overloaded'
|
|
1530
|
+
? COLOR_OVERLOADED
|
|
1531
|
+
: rpsSeverity === 'warning'
|
|
1532
|
+
? COLOR_WARNING
|
|
1533
|
+
: mutedColor;
|
|
1261
1534
|
const rpsInverted = rpsSeverity !== 'normal';
|
|
1262
|
-
const rpsText =
|
|
1263
|
-
|
|
1264
|
-
|
|
1535
|
+
const rpsText =
|
|
1536
|
+
effectiveCap > 0 && !node.isEdge
|
|
1537
|
+
? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
|
|
1538
|
+
: formatRpsShort(node.computedRps);
|
|
1265
1539
|
computedSection.push({
|
|
1266
|
-
key: 'RPS',
|
|
1267
|
-
|
|
1540
|
+
key: 'RPS',
|
|
1541
|
+
value: rpsText,
|
|
1542
|
+
valueFill: rpsColor,
|
|
1543
|
+
fontWeight: '500',
|
|
1544
|
+
inverted: rpsInverted,
|
|
1545
|
+
invertedBg: rpsInverted ? rpsColor : undefined,
|
|
1268
1546
|
});
|
|
1269
1547
|
}
|
|
1270
1548
|
for (const cr of computedRows) {
|
|
1271
1549
|
computedSection.push({
|
|
1272
|
-
key: cr.key,
|
|
1550
|
+
key: cr.key,
|
|
1551
|
+
value: cr.value,
|
|
1273
1552
|
valueFill: cr.color ?? mutedColor,
|
|
1274
1553
|
fontWeight: 'normal',
|
|
1275
1554
|
inverted: cr.inverted,
|
|
@@ -1282,16 +1561,34 @@ function renderNodes(
|
|
|
1282
1561
|
let propColor = textFill;
|
|
1283
1562
|
let inverted = false;
|
|
1284
1563
|
let invertedBg: string | undefined;
|
|
1285
|
-
if (
|
|
1564
|
+
if (
|
|
1565
|
+
prop.key === 'ratelimit-rps' &&
|
|
1566
|
+
nodeRateLimit > 0 &&
|
|
1567
|
+
node.computedRps > 0
|
|
1568
|
+
) {
|
|
1286
1569
|
let preRl = node.computedRps;
|
|
1287
1570
|
const ch = getNodeNumProp(node, 'cache-hit', 0);
|
|
1288
1571
|
if (ch > 0) preRl *= (100 - ch) / 100;
|
|
1289
1572
|
const fw = getNodeNumProp(node, 'firewall-block', 0);
|
|
1290
1573
|
if (fw > 0) preRl *= (100 - fw) / 100;
|
|
1291
|
-
if (preRl > nodeRateLimit) {
|
|
1292
|
-
|
|
1574
|
+
if (preRl > nodeRateLimit) {
|
|
1575
|
+
propColor = COLOR_OVERLOADED;
|
|
1576
|
+
inverted = true;
|
|
1577
|
+
invertedBg = COLOR_OVERLOADED;
|
|
1578
|
+
} else if (preRl > nodeRateLimit * 0.8) {
|
|
1579
|
+
propColor = COLOR_WARNING;
|
|
1580
|
+
inverted = true;
|
|
1581
|
+
invertedBg = COLOR_WARNING;
|
|
1582
|
+
}
|
|
1293
1583
|
}
|
|
1294
|
-
declaredSection.push({
|
|
1584
|
+
declaredSection.push({
|
|
1585
|
+
key: prop.displayKey,
|
|
1586
|
+
value: prop.value,
|
|
1587
|
+
valueFill: propColor,
|
|
1588
|
+
fontWeight: 'normal',
|
|
1589
|
+
inverted,
|
|
1590
|
+
invertedBg,
|
|
1591
|
+
});
|
|
1295
1592
|
}
|
|
1296
1593
|
|
|
1297
1594
|
const rows = [...computedSection, ...declaredSection];
|
|
@@ -1301,7 +1598,8 @@ function renderNodes(
|
|
|
1301
1598
|
const valueX = x + 10 + (maxKeyLen + 2) * (META_FONT_SIZE * 0.6);
|
|
1302
1599
|
|
|
1303
1600
|
let rowY = sepY + NODE_SEPARATOR_GAP + META_FONT_SIZE;
|
|
1304
|
-
const needsSectionSep =
|
|
1601
|
+
const needsSectionSep =
|
|
1602
|
+
computedSection.length > 0 && declaredSection.length > 0;
|
|
1305
1603
|
let rowIdx = 0;
|
|
1306
1604
|
for (const row of rows) {
|
|
1307
1605
|
// Draw separator line between computed and declared sections
|
|
@@ -1380,8 +1678,14 @@ function renderNodes(
|
|
|
1380
1678
|
// Instance badge — clickable for interactive adjustment (not for edge or serverless nodes)
|
|
1381
1679
|
// Serverless nodes show instances in a computed row instead (demand / concurrency).
|
|
1382
1680
|
// Nodes inside a scaled group suppress their badge — the group header already shows Nx.
|
|
1383
|
-
const inScaledGroup =
|
|
1384
|
-
|
|
1681
|
+
const inScaledGroup =
|
|
1682
|
+
node.groupId != null && (scaledGroupIds?.has(node.groupId) ?? false);
|
|
1683
|
+
if (
|
|
1684
|
+
!node.isEdge &&
|
|
1685
|
+
node.computedConcurrentInvocations === 0 &&
|
|
1686
|
+
node.computedInstances > 1 &&
|
|
1687
|
+
!inScaledGroup
|
|
1688
|
+
) {
|
|
1385
1689
|
const badgeText = `${node.computedInstances}x`;
|
|
1386
1690
|
g.append('text')
|
|
1387
1691
|
.attr('x', x + node.width - 6)
|
|
@@ -1396,7 +1700,8 @@ function renderNodes(
|
|
|
1396
1700
|
}
|
|
1397
1701
|
|
|
1398
1702
|
// Role badge dots — only shown when Capabilities legend is expanded
|
|
1399
|
-
const showDots =
|
|
1703
|
+
const showDots =
|
|
1704
|
+
activeGroup != null && activeGroup.toLowerCase() === 'capabilities';
|
|
1400
1705
|
const roles = showDots && !node.isEdge ? inferRoles(node.properties) : [];
|
|
1401
1706
|
if (roles.length > 0) {
|
|
1402
1707
|
// Move dots up above the collapse bar for collapsed groups
|
|
@@ -1417,10 +1722,13 @@ function renderNodes(
|
|
|
1417
1722
|
// Collapse bar at bottom of collapsed group nodes (accent stripe, clipped to card)
|
|
1418
1723
|
if (isCollapsedGroup) {
|
|
1419
1724
|
const clipId = `clip-${node.id.replace(/[[\]\s]/g, '')}`;
|
|
1420
|
-
g.append('clipPath')
|
|
1725
|
+
g.append('clipPath')
|
|
1726
|
+
.attr('id', clipId)
|
|
1421
1727
|
.append('rect')
|
|
1422
|
-
.attr('x', x)
|
|
1423
|
-
.attr('
|
|
1728
|
+
.attr('x', x)
|
|
1729
|
+
.attr('y', y)
|
|
1730
|
+
.attr('width', node.width)
|
|
1731
|
+
.attr('height', node.height)
|
|
1424
1732
|
.attr('rx', NODE_BORDER_RADIUS);
|
|
1425
1733
|
g.append('rect')
|
|
1426
1734
|
.attr('x', x + COLLAPSE_BAR_INSET)
|
|
@@ -1440,7 +1748,11 @@ function renderNodes(
|
|
|
1440
1748
|
// ============================================================
|
|
1441
1749
|
|
|
1442
1750
|
/** Get a numeric property from an InfraLayoutNode. */
|
|
1443
|
-
function getNodeNumProp(
|
|
1751
|
+
function getNodeNumProp(
|
|
1752
|
+
node: InfraLayoutNode,
|
|
1753
|
+
key: string,
|
|
1754
|
+
fallback: number
|
|
1755
|
+
): number {
|
|
1444
1756
|
const prop = node.properties.find((p) => p.key === key);
|
|
1445
1757
|
if (!prop) return fallback;
|
|
1446
1758
|
if (typeof prop.value === 'number') return prop.value;
|
|
@@ -1498,7 +1810,7 @@ function computeRejectedRps(node: InfraLayoutNode): number {
|
|
|
1498
1810
|
function renderRejectParticles(
|
|
1499
1811
|
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1500
1812
|
nodes: InfraLayoutNode[],
|
|
1501
|
-
speedMultiplier: number = 1
|
|
1813
|
+
speedMultiplier: number = 1
|
|
1502
1814
|
) {
|
|
1503
1815
|
// Compute max rejected RPS across all nodes for scaling
|
|
1504
1816
|
const rejectMap: { node: InfraLayoutNode; rejected: number }[] = [];
|
|
@@ -1512,8 +1824,11 @@ function renderRejectParticles(
|
|
|
1512
1824
|
|
|
1513
1825
|
for (const { node, rejected } of rejectMap) {
|
|
1514
1826
|
const t = Math.min(rejected / maxRejected, 1);
|
|
1515
|
-
const count = Math.round(
|
|
1516
|
-
|
|
1827
|
+
const count = Math.round(
|
|
1828
|
+
REJECT_COUNT_MIN + t * (REJECT_COUNT_MAX - REJECT_COUNT_MIN)
|
|
1829
|
+
);
|
|
1830
|
+
const baseDur =
|
|
1831
|
+
REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
|
|
1517
1832
|
const dur = speedMultiplier > 0 ? baseDur / speedMultiplier : baseDur;
|
|
1518
1833
|
|
|
1519
1834
|
const nodeBottom = node.y + node.height / 2;
|
|
@@ -1522,19 +1837,24 @@ function renderRejectParticles(
|
|
|
1522
1837
|
const delay = (dur / count) * i;
|
|
1523
1838
|
// Spread particles horizontally around the node center
|
|
1524
1839
|
const spread = node.width * 0.3;
|
|
1525
|
-
const startX =
|
|
1840
|
+
const startX =
|
|
1841
|
+
node.x + (count > 1 ? -spread / 2 + spread * (i / (count - 1)) : 0);
|
|
1526
1842
|
const startY = nodeBottom;
|
|
1527
1843
|
const endY = nodeBottom + REJECT_DROP_DISTANCE;
|
|
1528
1844
|
|
|
1529
|
-
const rejectColor =
|
|
1530
|
-
|
|
1531
|
-
|
|
1845
|
+
const rejectColor =
|
|
1846
|
+
node.overloaded || node.childHealthState === 'overloaded'
|
|
1847
|
+
? COLOR_OVERLOADED
|
|
1848
|
+
: COLOR_WARNING;
|
|
1849
|
+
const circle = svg
|
|
1850
|
+
.append('circle')
|
|
1532
1851
|
.attr('r', REJECT_PARTICLE_R)
|
|
1533
1852
|
.attr('fill', rejectColor)
|
|
1534
1853
|
.attr('opacity', 0);
|
|
1535
1854
|
|
|
1536
1855
|
// Drop straight down from node bottom
|
|
1537
|
-
circle
|
|
1856
|
+
circle
|
|
1857
|
+
.append('animate')
|
|
1538
1858
|
.attr('attributeName', 'cy')
|
|
1539
1859
|
.attr('from', startY)
|
|
1540
1860
|
.attr('to', endY)
|
|
@@ -1542,7 +1862,8 @@ function renderRejectParticles(
|
|
|
1542
1862
|
.attr('repeatCount', 'indefinite')
|
|
1543
1863
|
.attr('begin', `${delay}s`);
|
|
1544
1864
|
|
|
1545
|
-
circle
|
|
1865
|
+
circle
|
|
1866
|
+
.append('animate')
|
|
1546
1867
|
.attr('attributeName', 'cx')
|
|
1547
1868
|
.attr('from', startX)
|
|
1548
1869
|
.attr('to', startX)
|
|
@@ -1551,7 +1872,8 @@ function renderRejectParticles(
|
|
|
1551
1872
|
.attr('begin', `${delay}s`);
|
|
1552
1873
|
|
|
1553
1874
|
// Fade: appear quickly, then fade out
|
|
1554
|
-
circle
|
|
1875
|
+
circle
|
|
1876
|
+
.append('animate')
|
|
1555
1877
|
.attr('attributeName', 'opacity')
|
|
1556
1878
|
.attr('values', '0;0.8;0.6;0')
|
|
1557
1879
|
.attr('keyTimes', '0;0.1;0.5;1')
|
|
@@ -1588,12 +1910,14 @@ export function computeInfraLegendGroups(
|
|
|
1588
1910
|
nodes: InfraLayoutNode[],
|
|
1589
1911
|
tagGroups: InfraTagGroup[],
|
|
1590
1912
|
palette: PaletteColors,
|
|
1591
|
-
edges?: InfraLayoutEdge[]
|
|
1913
|
+
edges?: InfraLayoutEdge[]
|
|
1592
1914
|
): InfraLegendGroup[] {
|
|
1593
1915
|
const groups: InfraLegendGroup[] = [];
|
|
1594
1916
|
|
|
1595
1917
|
// Capabilities group (from inferred roles + fanout edges)
|
|
1596
|
-
const roles = collectDiagramRoles(
|
|
1918
|
+
const roles = collectDiagramRoles(
|
|
1919
|
+
nodes.filter((n) => !n.isEdge).map((n) => n.properties)
|
|
1920
|
+
);
|
|
1597
1921
|
if (edges && collectFanoutSourceIds(edges).size > 0) {
|
|
1598
1922
|
roles.push(FANOUT_ROLE);
|
|
1599
1923
|
}
|
|
@@ -1603,10 +1927,16 @@ export function computeInfraLegendGroups(
|
|
|
1603
1927
|
color: r.color,
|
|
1604
1928
|
key: r.name.toLowerCase().replace(/\s+/g, '-'),
|
|
1605
1929
|
}));
|
|
1606
|
-
const pillWidth =
|
|
1930
|
+
const pillWidth =
|
|
1931
|
+
measureLegendText('Capabilities', LEGEND_PILL_FONT_SIZE) +
|
|
1932
|
+
LEGEND_PILL_PAD;
|
|
1607
1933
|
let entriesWidth = 0;
|
|
1608
1934
|
for (const e of entries) {
|
|
1609
|
-
entriesWidth +=
|
|
1935
|
+
entriesWidth +=
|
|
1936
|
+
LEGEND_DOT_R * 2 +
|
|
1937
|
+
LEGEND_ENTRY_DOT_GAP +
|
|
1938
|
+
measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
1939
|
+
LEGEND_ENTRY_TRAIL;
|
|
1610
1940
|
}
|
|
1611
1941
|
groups.push({
|
|
1612
1942
|
name: 'Capabilities',
|
|
@@ -1630,10 +1960,15 @@ export function computeInfraLegendGroups(
|
|
|
1630
1960
|
}
|
|
1631
1961
|
}
|
|
1632
1962
|
if (entries.length === 0) continue;
|
|
1633
|
-
const pillWidth =
|
|
1963
|
+
const pillWidth =
|
|
1964
|
+
measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
1634
1965
|
let entriesWidth = 0;
|
|
1635
1966
|
for (const e of entries) {
|
|
1636
|
-
entriesWidth +=
|
|
1967
|
+
entriesWidth +=
|
|
1968
|
+
LEGEND_DOT_R * 2 +
|
|
1969
|
+
LEGEND_ENTRY_DOT_GAP +
|
|
1970
|
+
measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
1971
|
+
LEGEND_ENTRY_TRAIL;
|
|
1637
1972
|
}
|
|
1638
1973
|
groups.push({
|
|
1639
1974
|
name: tg.name,
|
|
@@ -1649,15 +1984,21 @@ export function computeInfraLegendGroups(
|
|
|
1649
1984
|
}
|
|
1650
1985
|
|
|
1651
1986
|
/** Compute total width for the playback pill (speed only). */
|
|
1652
|
-
function computePlaybackWidth(
|
|
1987
|
+
function computePlaybackWidth(
|
|
1988
|
+
playback: InfraPlaybackState | undefined
|
|
1989
|
+
): number {
|
|
1653
1990
|
if (!playback) return 0;
|
|
1654
|
-
const pillWidth =
|
|
1991
|
+
const pillWidth =
|
|
1992
|
+
measureLegendText('Playback', LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
1655
1993
|
if (!playback.expanded) return pillWidth;
|
|
1656
1994
|
|
|
1657
1995
|
let entriesW = 8; // gap after pill
|
|
1658
1996
|
entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
|
|
1659
1997
|
for (const s of playback.speedOptions) {
|
|
1660
|
-
entriesW +=
|
|
1998
|
+
entriesW +=
|
|
1999
|
+
measureLegendText(`${s}x`, LEGEND_ENTRY_FONT_SIZE) +
|
|
2000
|
+
SPEED_BADGE_H_PAD * 2 +
|
|
2001
|
+
SPEED_BADGE_GAP;
|
|
1661
2002
|
}
|
|
1662
2003
|
return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
|
|
1663
2004
|
}
|
|
@@ -1670,11 +2011,12 @@ function renderLegend(
|
|
|
1670
2011
|
palette: PaletteColors,
|
|
1671
2012
|
isDark: boolean,
|
|
1672
2013
|
activeGroup: string | null,
|
|
1673
|
-
playback?: InfraPlaybackState
|
|
2014
|
+
playback?: InfraPlaybackState
|
|
1674
2015
|
) {
|
|
1675
2016
|
if (legendGroups.length === 0 && !playback) return;
|
|
1676
2017
|
|
|
1677
|
-
const legendG = rootSvg
|
|
2018
|
+
const legendG = rootSvg
|
|
2019
|
+
.append('g')
|
|
1678
2020
|
.attr('transform', `translate(0, ${legendY})`);
|
|
1679
2021
|
|
|
1680
2022
|
if (activeGroup) {
|
|
@@ -1683,23 +2025,31 @@ function renderLegend(
|
|
|
1683
2025
|
|
|
1684
2026
|
// Compute centered positions
|
|
1685
2027
|
const effectiveW = (g: InfraLegendGroup) =>
|
|
1686
|
-
activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase()
|
|
2028
|
+
activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase()
|
|
2029
|
+
? g.width
|
|
2030
|
+
: g.minifiedWidth;
|
|
1687
2031
|
const playbackW = computePlaybackWidth(playback);
|
|
1688
|
-
const trailingGaps =
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
+
|
|
2032
|
+
const trailingGaps =
|
|
2033
|
+
legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
|
|
2034
|
+
const totalLegendW =
|
|
2035
|
+
legendGroups.reduce((s, g) => s + effectiveW(g), 0) +
|
|
2036
|
+
(legendGroups.length - 1) * LEGEND_GROUP_GAP +
|
|
2037
|
+
trailingGaps +
|
|
2038
|
+
playbackW;
|
|
1692
2039
|
let cursorX = (totalWidth - totalLegendW) / 2;
|
|
1693
2040
|
|
|
1694
2041
|
for (const group of legendGroups) {
|
|
1695
|
-
const isActive =
|
|
2042
|
+
const isActive =
|
|
2043
|
+
activeGroup != null &&
|
|
2044
|
+
group.name.toLowerCase() === activeGroup.toLowerCase();
|
|
1696
2045
|
|
|
1697
2046
|
const groupBg = isDark
|
|
1698
2047
|
? mix(palette.surface, palette.bg, 50)
|
|
1699
2048
|
: mix(palette.surface, palette.bg, 30);
|
|
1700
2049
|
|
|
1701
2050
|
const pillLabel = group.name;
|
|
1702
|
-
const pillWidth =
|
|
2051
|
+
const pillWidth =
|
|
2052
|
+
measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
1703
2053
|
|
|
1704
2054
|
const gEl = legendG
|
|
1705
2055
|
.append('g')
|
|
@@ -1710,7 +2060,8 @@ function renderLegend(
|
|
|
1710
2060
|
|
|
1711
2061
|
// Outer capsule background (active only)
|
|
1712
2062
|
if (isActive) {
|
|
1713
|
-
gEl
|
|
2063
|
+
gEl
|
|
2064
|
+
.append('rect')
|
|
1714
2065
|
.attr('width', group.width)
|
|
1715
2066
|
.attr('height', LEGEND_HEIGHT)
|
|
1716
2067
|
.attr('rx', LEGEND_HEIGHT / 2)
|
|
@@ -1722,7 +2073,8 @@ function renderLegend(
|
|
|
1722
2073
|
const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
1723
2074
|
|
|
1724
2075
|
// Pill background
|
|
1725
|
-
gEl
|
|
2076
|
+
gEl
|
|
2077
|
+
.append('rect')
|
|
1726
2078
|
.attr('x', pillXOff)
|
|
1727
2079
|
.attr('y', pillYOff)
|
|
1728
2080
|
.attr('width', pillWidth)
|
|
@@ -1732,7 +2084,8 @@ function renderLegend(
|
|
|
1732
2084
|
|
|
1733
2085
|
// Active pill border
|
|
1734
2086
|
if (isActive) {
|
|
1735
|
-
gEl
|
|
2087
|
+
gEl
|
|
2088
|
+
.append('rect')
|
|
1736
2089
|
.attr('x', pillXOff)
|
|
1737
2090
|
.attr('y', pillYOff)
|
|
1738
2091
|
.attr('width', pillWidth)
|
|
@@ -1744,7 +2097,8 @@ function renderLegend(
|
|
|
1744
2097
|
}
|
|
1745
2098
|
|
|
1746
2099
|
// Pill text
|
|
1747
|
-
gEl
|
|
2100
|
+
gEl
|
|
2101
|
+
.append('text')
|
|
1748
2102
|
.attr('x', pillXOff + pillWidth / 2)
|
|
1749
2103
|
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
1750
2104
|
.attr('font-family', FONT_FAMILY)
|
|
@@ -1764,17 +2118,22 @@ function renderLegend(
|
|
|
1764
2118
|
.attr('data-legend-entry', entry.key.toLowerCase())
|
|
1765
2119
|
.attr('data-legend-color', entry.color)
|
|
1766
2120
|
.attr('data-legend-type', group.type)
|
|
1767
|
-
.attr(
|
|
2121
|
+
.attr(
|
|
2122
|
+
'data-legend-tag-group',
|
|
2123
|
+
group.type === 'tag' ? (group.tagKey ?? '') : null
|
|
2124
|
+
)
|
|
1768
2125
|
.style('cursor', 'pointer');
|
|
1769
2126
|
|
|
1770
|
-
entryG
|
|
2127
|
+
entryG
|
|
2128
|
+
.append('circle')
|
|
1771
2129
|
.attr('cx', entryX + LEGEND_DOT_R)
|
|
1772
2130
|
.attr('cy', LEGEND_HEIGHT / 2)
|
|
1773
2131
|
.attr('r', LEGEND_DOT_R)
|
|
1774
2132
|
.attr('fill', entry.color);
|
|
1775
2133
|
|
|
1776
2134
|
const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
|
|
1777
|
-
entryG
|
|
2135
|
+
entryG
|
|
2136
|
+
.append('text')
|
|
1778
2137
|
.attr('x', textX)
|
|
1779
2138
|
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
|
|
1780
2139
|
.attr('font-family', FONT_FAMILY)
|
|
@@ -1782,7 +2141,10 @@ function renderLegend(
|
|
|
1782
2141
|
.attr('fill', palette.textMuted)
|
|
1783
2142
|
.text(entry.value);
|
|
1784
2143
|
|
|
1785
|
-
entryX =
|
|
2144
|
+
entryX =
|
|
2145
|
+
textX +
|
|
2146
|
+
measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
2147
|
+
LEGEND_ENTRY_TRAIL;
|
|
1786
2148
|
}
|
|
1787
2149
|
}
|
|
1788
2150
|
|
|
@@ -1797,7 +2159,8 @@ function renderLegend(
|
|
|
1797
2159
|
: mix(palette.bg, palette.text, 92);
|
|
1798
2160
|
|
|
1799
2161
|
const pillLabel = 'Playback';
|
|
1800
|
-
const pillWidth =
|
|
2162
|
+
const pillWidth =
|
|
2163
|
+
measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
1801
2164
|
const fullW = computePlaybackWidth(playback);
|
|
1802
2165
|
|
|
1803
2166
|
const pbG = legendG
|
|
@@ -1807,7 +2170,8 @@ function renderLegend(
|
|
|
1807
2170
|
.style('cursor', 'pointer');
|
|
1808
2171
|
|
|
1809
2172
|
if (isExpanded) {
|
|
1810
|
-
pbG
|
|
2173
|
+
pbG
|
|
2174
|
+
.append('rect')
|
|
1811
2175
|
.attr('width', fullW)
|
|
1812
2176
|
.attr('height', LEGEND_HEIGHT)
|
|
1813
2177
|
.attr('rx', LEGEND_HEIGHT / 2)
|
|
@@ -1818,23 +2182,30 @@ function renderLegend(
|
|
|
1818
2182
|
const pillYOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
|
|
1819
2183
|
const pillH = LEGEND_HEIGHT - (isExpanded ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
1820
2184
|
|
|
1821
|
-
pbG
|
|
1822
|
-
.
|
|
1823
|
-
.attr('
|
|
2185
|
+
pbG
|
|
2186
|
+
.append('rect')
|
|
2187
|
+
.attr('x', pillXOff)
|
|
2188
|
+
.attr('y', pillYOff)
|
|
2189
|
+
.attr('width', pillWidth)
|
|
2190
|
+
.attr('height', pillH)
|
|
1824
2191
|
.attr('rx', pillH / 2)
|
|
1825
2192
|
.attr('fill', isExpanded ? palette.bg : groupBg);
|
|
1826
2193
|
|
|
1827
2194
|
if (isExpanded) {
|
|
1828
|
-
pbG
|
|
1829
|
-
.
|
|
1830
|
-
.attr('
|
|
2195
|
+
pbG
|
|
2196
|
+
.append('rect')
|
|
2197
|
+
.attr('x', pillXOff)
|
|
2198
|
+
.attr('y', pillYOff)
|
|
2199
|
+
.attr('width', pillWidth)
|
|
2200
|
+
.attr('height', pillH)
|
|
1831
2201
|
.attr('rx', pillH / 2)
|
|
1832
2202
|
.attr('fill', 'none')
|
|
1833
2203
|
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
1834
2204
|
.attr('stroke-width', 0.75);
|
|
1835
2205
|
}
|
|
1836
2206
|
|
|
1837
|
-
pbG
|
|
2207
|
+
pbG
|
|
2208
|
+
.append('text')
|
|
1838
2209
|
.attr('x', pillXOff + pillWidth / 2)
|
|
1839
2210
|
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
1840
2211
|
.attr('font-family', FONT_FAMILY)
|
|
@@ -1849,8 +2220,10 @@ function renderLegend(
|
|
|
1849
2220
|
const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
|
|
1850
2221
|
|
|
1851
2222
|
const ppLabel = playback.paused ? '▶' : '⏸';
|
|
1852
|
-
pbG
|
|
1853
|
-
.
|
|
2223
|
+
pbG
|
|
2224
|
+
.append('text')
|
|
2225
|
+
.attr('x', entryX)
|
|
2226
|
+
.attr('y', entryY)
|
|
1854
2227
|
.attr('font-family', FONT_FAMILY)
|
|
1855
2228
|
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
1856
2229
|
.attr('fill', palette.textMuted)
|
|
@@ -1862,16 +2235,20 @@ function renderLegend(
|
|
|
1862
2235
|
for (const s of playback.speedOptions) {
|
|
1863
2236
|
const label = `${s}x`;
|
|
1864
2237
|
const isActive = playback.speed === s;
|
|
1865
|
-
const slotW =
|
|
2238
|
+
const slotW =
|
|
2239
|
+
measureLegendText(label, LEGEND_ENTRY_FONT_SIZE) +
|
|
2240
|
+
SPEED_BADGE_H_PAD * 2;
|
|
1866
2241
|
const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
|
|
1867
2242
|
const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
|
|
1868
2243
|
|
|
1869
|
-
const speedG = pbG
|
|
2244
|
+
const speedG = pbG
|
|
2245
|
+
.append('g')
|
|
1870
2246
|
.attr('data-playback-action', 'set-speed')
|
|
1871
2247
|
.attr('data-playback-value', String(s))
|
|
1872
2248
|
.style('cursor', 'pointer');
|
|
1873
2249
|
|
|
1874
|
-
speedG
|
|
2250
|
+
speedG
|
|
2251
|
+
.append('rect')
|
|
1875
2252
|
.attr('x', entryX)
|
|
1876
2253
|
.attr('y', badgeY)
|
|
1877
2254
|
.attr('width', slotW)
|
|
@@ -1879,8 +2256,10 @@ function renderLegend(
|
|
|
1879
2256
|
.attr('rx', badgeH / 2)
|
|
1880
2257
|
.attr('fill', isActive ? palette.primary : 'transparent');
|
|
1881
2258
|
|
|
1882
|
-
speedG
|
|
1883
|
-
.
|
|
2259
|
+
speedG
|
|
2260
|
+
.append('text')
|
|
2261
|
+
.attr('x', entryX + slotW / 2)
|
|
2262
|
+
.attr('y', entryY)
|
|
1884
2263
|
.attr('font-family', FONT_FAMILY)
|
|
1885
2264
|
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
1886
2265
|
.attr('font-weight', isActive ? '600' : '400')
|
|
@@ -1891,9 +2270,8 @@ function renderLegend(
|
|
|
1891
2270
|
}
|
|
1892
2271
|
}
|
|
1893
2272
|
|
|
1894
|
-
cursorX += fullW + LEGEND_GROUP_GAP;
|
|
2273
|
+
cursorX += fullW + LEGEND_GROUP_GAP; // eslint-disable-line no-useless-assignment
|
|
1895
2274
|
}
|
|
1896
|
-
|
|
1897
2275
|
}
|
|
1898
2276
|
|
|
1899
2277
|
// ============================================================
|
|
@@ -1920,13 +2298,18 @@ export function renderInfra(
|
|
|
1920
2298
|
playback?: InfraPlaybackState | null,
|
|
1921
2299
|
expandedNodeIds?: Set<string> | null,
|
|
1922
2300
|
exportMode?: boolean,
|
|
1923
|
-
collapsedNodes?: Set<string> | null
|
|
2301
|
+
collapsedNodes?: Set<string> | null
|
|
1924
2302
|
) {
|
|
1925
2303
|
// Clear previous render (preserve tooltips if any)
|
|
1926
2304
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
1927
2305
|
|
|
1928
2306
|
// Build legend groups
|
|
1929
|
-
const legendGroups = computeInfraLegendGroups(
|
|
2307
|
+
const legendGroups = computeInfraLegendGroups(
|
|
2308
|
+
layout.nodes,
|
|
2309
|
+
tagGroups ?? [],
|
|
2310
|
+
palette,
|
|
2311
|
+
layout.edges
|
|
2312
|
+
);
|
|
1930
2313
|
const hasLegend = legendGroups.length > 0 || !!playback;
|
|
1931
2314
|
// In app mode (not export), legend is rendered as a separate fixed-size SVG
|
|
1932
2315
|
const fixedLegend = !exportMode && hasLegend;
|
|
@@ -1947,7 +2330,8 @@ export function renderInfra(
|
|
|
1947
2330
|
|
|
1948
2331
|
if (fixedTitleH) {
|
|
1949
2332
|
const titleContainerW = container.clientWidth || totalWidth;
|
|
1950
|
-
const titleSvg = d3Selection
|
|
2333
|
+
const titleSvg = d3Selection
|
|
2334
|
+
.select(container)
|
|
1951
2335
|
.append('svg')
|
|
1952
2336
|
.attr('class', 'infra-title-fixed')
|
|
1953
2337
|
.attr('width', '100%')
|
|
@@ -1955,7 +2339,8 @@ export function renderInfra(
|
|
|
1955
2339
|
.attr('viewBox', `0 0 ${titleContainerW} ${fixedTitleH}`)
|
|
1956
2340
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
1957
2341
|
.style('display', 'block');
|
|
1958
|
-
titleSvg
|
|
2342
|
+
titleSvg
|
|
2343
|
+
.append('text')
|
|
1959
2344
|
.attr('class', 'chart-title')
|
|
1960
2345
|
.attr('x', titleContainerW / 2)
|
|
1961
2346
|
.attr('y', TITLE_Y)
|
|
@@ -1968,12 +2353,17 @@ export function renderInfra(
|
|
|
1968
2353
|
.text(title!);
|
|
1969
2354
|
}
|
|
1970
2355
|
|
|
1971
|
-
const fixedOverheadH =
|
|
1972
|
-
|
|
2356
|
+
const fixedOverheadH =
|
|
2357
|
+
(fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0) + fixedTitleH;
|
|
2358
|
+
const rootSvg = d3Selection
|
|
2359
|
+
.select(container)
|
|
1973
2360
|
.append('svg')
|
|
1974
2361
|
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
1975
2362
|
.attr('width', '100%')
|
|
1976
|
-
.attr(
|
|
2363
|
+
.attr(
|
|
2364
|
+
'height',
|
|
2365
|
+
fixedOverheadH > 0 ? `calc(100% - ${fixedOverheadH}px)` : '100%'
|
|
2366
|
+
)
|
|
1977
2367
|
.attr('viewBox', `0 0 ${totalWidth} ${diagramViewHeight}`)
|
|
1978
2368
|
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
1979
2369
|
|
|
@@ -2023,12 +2413,14 @@ export function renderInfra(
|
|
|
2023
2413
|
|
|
2024
2414
|
// Content group offset: skip title space (unless title was extracted to fixed SVG)
|
|
2025
2415
|
const contentTitleOffset = fixedTitleH ? 0 : titleOffset;
|
|
2026
|
-
const svg = rootSvg
|
|
2416
|
+
const svg = rootSvg
|
|
2417
|
+
.append('g')
|
|
2027
2418
|
.attr('transform', `translate(0, ${contentTitleOffset + legendOffset})`);
|
|
2028
2419
|
|
|
2029
2420
|
// Title (inside rootSvg when not using fixed title)
|
|
2030
2421
|
if (title && !fixedTitleH) {
|
|
2031
|
-
rootSvg
|
|
2422
|
+
rootSvg
|
|
2423
|
+
.append('text')
|
|
2032
2424
|
.attr('class', 'chart-title')
|
|
2033
2425
|
.attr('x', totalWidth / 2)
|
|
2034
2426
|
.attr('y', TITLE_Y)
|
|
@@ -2044,43 +2436,103 @@ export function renderInfra(
|
|
|
2044
2436
|
// Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
|
|
2045
2437
|
renderGroups(svg, layout.groups, palette, isDark);
|
|
2046
2438
|
const speedMultiplier = playback?.speed ?? 1;
|
|
2047
|
-
renderEdgePaths(
|
|
2439
|
+
renderEdgePaths(
|
|
2440
|
+
svg,
|
|
2441
|
+
layout.edges,
|
|
2442
|
+
layout.nodes,
|
|
2443
|
+
layout.groups,
|
|
2444
|
+
palette,
|
|
2445
|
+
isDark,
|
|
2446
|
+
shouldAnimate,
|
|
2447
|
+
layout.direction,
|
|
2448
|
+
speedMultiplier
|
|
2449
|
+
);
|
|
2048
2450
|
const fanoutSourceIds = collectFanoutSourceIds(layout.edges);
|
|
2049
2451
|
const scaledGroupIds = new Set<string>(
|
|
2050
2452
|
layout.groups
|
|
2051
2453
|
.filter((g) => {
|
|
2052
|
-
const gi =
|
|
2053
|
-
|
|
2454
|
+
const gi =
|
|
2455
|
+
typeof g.instances === 'number'
|
|
2456
|
+
? g.instances
|
|
2457
|
+
: typeof g.instances === 'string'
|
|
2458
|
+
? parseInt(String(g.instances), 10) || 0
|
|
2459
|
+
: 0;
|
|
2054
2460
|
return gi > 1;
|
|
2055
2461
|
})
|
|
2056
2462
|
.map((g) => g.id)
|
|
2057
2463
|
);
|
|
2058
|
-
renderNodes(
|
|
2464
|
+
renderNodes(
|
|
2465
|
+
svg,
|
|
2466
|
+
layout.nodes,
|
|
2467
|
+
palette,
|
|
2468
|
+
isDark,
|
|
2469
|
+
shouldAnimate,
|
|
2470
|
+
expandedNodeIds,
|
|
2471
|
+
activeGroup,
|
|
2472
|
+
layout.options,
|
|
2473
|
+
collapsedNodes,
|
|
2474
|
+
tagGroups ?? [],
|
|
2475
|
+
fanoutSourceIds,
|
|
2476
|
+
scaledGroupIds
|
|
2477
|
+
);
|
|
2059
2478
|
if (shouldAnimate) {
|
|
2060
2479
|
renderRejectParticles(svg, layout.nodes, speedMultiplier);
|
|
2061
2480
|
}
|
|
2062
|
-
renderEdgeLabels(
|
|
2481
|
+
renderEdgeLabels(
|
|
2482
|
+
svg,
|
|
2483
|
+
layout.edges,
|
|
2484
|
+
layout.nodes,
|
|
2485
|
+
layout.groups,
|
|
2486
|
+
palette,
|
|
2487
|
+
isDark,
|
|
2488
|
+
shouldAnimate,
|
|
2489
|
+
layout.direction
|
|
2490
|
+
);
|
|
2063
2491
|
|
|
2064
2492
|
// Legend at top
|
|
2065
2493
|
if (hasLegend) {
|
|
2066
2494
|
if (fixedLegend) {
|
|
2067
2495
|
// Render legend in a separate SVG that stays at fixed pixel size, inserted between title and diagram
|
|
2068
2496
|
const containerWidth = container.clientWidth || totalWidth;
|
|
2069
|
-
const legendSvg = d3Selection
|
|
2497
|
+
const legendSvg = d3Selection
|
|
2498
|
+
.select(container)
|
|
2070
2499
|
.insert('svg', 'svg:last-of-type')
|
|
2071
2500
|
.attr('class', 'infra-legend-fixed')
|
|
2072
2501
|
.attr('width', '100%')
|
|
2073
2502
|
.attr('height', LEGEND_HEIGHT + LEGEND_FIXED_GAP)
|
|
2074
|
-
.attr(
|
|
2503
|
+
.attr(
|
|
2504
|
+
'viewBox',
|
|
2505
|
+
`0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`
|
|
2506
|
+
)
|
|
2075
2507
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
2076
2508
|
.style('display', 'block')
|
|
2077
2509
|
.style('pointer-events', 'none');
|
|
2078
|
-
renderLegend(
|
|
2510
|
+
renderLegend(
|
|
2511
|
+
legendSvg,
|
|
2512
|
+
legendGroups,
|
|
2513
|
+
containerWidth,
|
|
2514
|
+
LEGEND_FIXED_GAP / 2,
|
|
2515
|
+
palette,
|
|
2516
|
+
isDark,
|
|
2517
|
+
activeGroup ?? null,
|
|
2518
|
+
playback ?? undefined
|
|
2519
|
+
);
|
|
2079
2520
|
// Re-enable pointer events on interactive legend elements
|
|
2080
|
-
legendSvg
|
|
2521
|
+
legendSvg
|
|
2522
|
+
.selectAll('.infra-legend-group')
|
|
2523
|
+
.style('pointer-events', 'auto');
|
|
2081
2524
|
} else {
|
|
2082
2525
|
// Export mode: render legend at top (below title)
|
|
2083
|
-
renderLegend(
|
|
2526
|
+
renderLegend(
|
|
2527
|
+
rootSvg,
|
|
2528
|
+
legendGroups,
|
|
2529
|
+
totalWidth,
|
|
2530
|
+
titleOffset,
|
|
2531
|
+
palette,
|
|
2532
|
+
isDark,
|
|
2533
|
+
activeGroup ?? null,
|
|
2534
|
+
playback ?? undefined
|
|
2535
|
+
);
|
|
2084
2536
|
}
|
|
2085
2537
|
}
|
|
2086
2538
|
}
|