@diagrammo/dgmo 0.4.2 → 0.4.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/skills/dgmo-chart/SKILL.md +28 -0
- package/.claude/skills/dgmo-generate/SKILL.md +1 -0
- package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
- package/.cursorrules +27 -2
- package/.github/copilot-instructions.md +36 -3
- package/.windsurfrules +27 -2
- package/README.md +12 -3
- package/dist/cli.cjs +197 -154
- package/dist/index.cjs +8647 -3447
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +503 -58
- package/dist/index.d.ts +503 -58
- package/dist/index.js +8379 -3200
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +336 -17
- package/docs/migration-sequence-color-to-tags.md +98 -0
- package/package.json +1 -1
- package/src/c4/renderer.ts +1 -20
- package/src/class/renderer.ts +1 -11
- package/src/cli.ts +40 -0
- package/src/d3.ts +92 -2
- package/src/dgmo-router.ts +11 -0
- package/src/echarts.ts +74 -8
- package/src/er/parser.ts +29 -3
- package/src/er/renderer.ts +1 -15
- package/src/graph/flowchart-parser.ts +7 -30
- package/src/graph/flowchart-renderer.ts +62 -69
- package/src/graph/layout.ts +5 -0
- package/src/graph/state-parser.ts +388 -0
- package/src/graph/state-renderer.ts +496 -0
- package/src/graph/types.ts +4 -2
- package/src/index.ts +42 -1
- package/src/infra/compute.ts +1113 -0
- package/src/infra/layout.ts +578 -0
- package/src/infra/parser.ts +559 -0
- package/src/infra/renderer.ts +1553 -0
- package/src/infra/roles.ts +60 -0
- package/src/infra/serialize.ts +67 -0
- package/src/infra/types.ts +221 -0
- package/src/infra/validation.ts +192 -0
- package/src/initiative-status/layout.ts +56 -61
- package/src/initiative-status/renderer.ts +13 -13
- package/src/kanban/renderer.ts +1 -24
- package/src/org/layout.ts +28 -37
- package/src/org/parser.ts +16 -1
- package/src/org/renderer.ts +159 -121
- package/src/org/resolver.ts +90 -23
- package/src/palettes/color-utils.ts +30 -0
- package/src/render.ts +2 -0
- package/src/sequence/parser.ts +202 -42
- package/src/sequence/renderer.ts +576 -113
- package/src/sequence/tag-resolution.ts +163 -0
- package/src/sharing.ts +8 -0
- package/src/sitemap/collapse.ts +187 -0
- package/src/sitemap/layout.ts +738 -0
- package/src/sitemap/parser.ts +489 -0
- package/src/sitemap/renderer.ts +774 -0
- package/src/sitemap/types.ts +42 -0
- package/src/utils/tag-groups.ts +119 -0
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Infra Chart SVG Renderer
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import * as d3Selection from 'd3-selection';
|
|
6
|
+
import * as d3Shape from 'd3-shape';
|
|
7
|
+
import { FONT_FAMILY } from '../fonts';
|
|
8
|
+
import type { PaletteColors } from '../palettes';
|
|
9
|
+
import { mix } from '../palettes/color-utils';
|
|
10
|
+
import type { ParsedInfra, InfraTagGroup } from './types';
|
|
11
|
+
import { resolveColor } from '../colors';
|
|
12
|
+
import type { ComputedInfraModel } from './types';
|
|
13
|
+
import type { InfraLayoutResult, InfraLayoutNode, InfraLayoutEdge, InfraLayoutGroup } from './layout';
|
|
14
|
+
import { inferRoles, collectDiagramRoles } from './roles';
|
|
15
|
+
import type { InfraRole } from './roles';
|
|
16
|
+
import { parseInfra } from './parser';
|
|
17
|
+
import { computeInfra } from './compute';
|
|
18
|
+
import { layoutInfra } from './layout';
|
|
19
|
+
|
|
20
|
+
// ============================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// ============================================================
|
|
23
|
+
|
|
24
|
+
const DIAGRAM_PADDING = 20;
|
|
25
|
+
const NODE_FONT_SIZE = 13;
|
|
26
|
+
const META_FONT_SIZE = 10;
|
|
27
|
+
const META_LINE_HEIGHT = 14;
|
|
28
|
+
const RPS_FONT_SIZE = 11;
|
|
29
|
+
const EDGE_LABEL_FONT_SIZE = 11;
|
|
30
|
+
const GROUP_LABEL_FONT_SIZE = 14;
|
|
31
|
+
const NODE_BORDER_RADIUS = 8;
|
|
32
|
+
const EDGE_STROKE_WIDTH = 1.5;
|
|
33
|
+
const NODE_STROKE_WIDTH = 1.5;
|
|
34
|
+
const OVERLOAD_STROKE_WIDTH = 3;
|
|
35
|
+
const ROLE_DOT_RADIUS = 3;
|
|
36
|
+
const NODE_HEADER_HEIGHT = 28;
|
|
37
|
+
const NODE_SEPARATOR_GAP = 4;
|
|
38
|
+
const NODE_PAD_BOTTOM = 10;
|
|
39
|
+
const COLLAPSE_BAR_HEIGHT = 6;
|
|
40
|
+
const COLLAPSE_BAR_INSET = 0;
|
|
41
|
+
|
|
42
|
+
// Legend pill/capsule constants (matching org chart style)
|
|
43
|
+
const LEGEND_HEIGHT = 28;
|
|
44
|
+
const LEGEND_PILL_PAD = 16;
|
|
45
|
+
const LEGEND_PILL_FONT_SIZE = 11;
|
|
46
|
+
const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
|
|
47
|
+
const LEGEND_CAPSULE_PAD = 4;
|
|
48
|
+
const LEGEND_DOT_R = 4;
|
|
49
|
+
const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
50
|
+
const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
|
|
51
|
+
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
52
|
+
const LEGEND_ENTRY_TRAIL = 8;
|
|
53
|
+
const LEGEND_GROUP_GAP = 12;
|
|
54
|
+
const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram
|
|
55
|
+
|
|
56
|
+
// Health colors (from UX spec)
|
|
57
|
+
const COLOR_HEALTHY = '#22c55e';
|
|
58
|
+
const COLOR_WARNING = '#eab308';
|
|
59
|
+
const COLOR_OVERLOADED = '#ef4444';
|
|
60
|
+
const COLOR_NEUTRAL = '#94a3b8';
|
|
61
|
+
|
|
62
|
+
/** A row in the node card body. `inverted` renders as a colored pill with light text. */
|
|
63
|
+
interface NodeRow {
|
|
64
|
+
key: string;
|
|
65
|
+
value: string;
|
|
66
|
+
valueFill: string;
|
|
67
|
+
fontWeight: string;
|
|
68
|
+
inverted?: boolean;
|
|
69
|
+
invertedBg?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Computed metric row (latency percentiles, availability, CB state). */
|
|
73
|
+
interface ComputedRow {
|
|
74
|
+
key: string;
|
|
75
|
+
value: string;
|
|
76
|
+
color?: string;
|
|
77
|
+
inverted?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Animation constants
|
|
81
|
+
const FLOW_DASH = '8 6'; // dash-array for animated edges
|
|
82
|
+
const FLOW_DASH_LEN = 14; // sum of dash + gap (for offset keyframe)
|
|
83
|
+
const FLOW_SPEED_MIN = 2.5; // seconds at max RPS
|
|
84
|
+
const FLOW_SPEED_MAX = 6; // seconds at min RPS
|
|
85
|
+
const OVERLOAD_PULSE_SPEED = 0.8; // seconds for overload pulse cycle
|
|
86
|
+
const PARTICLE_R = 5; // particle circle radius
|
|
87
|
+
const PARTICLE_COUNT_MIN = 1; // min particles per edge
|
|
88
|
+
const PARTICLE_COUNT_MAX = 4; // max particles per edge (at max RPS)
|
|
89
|
+
const NODE_PULSE_SPEED = 1.5; // seconds for warning pulse
|
|
90
|
+
const NODE_PULSE_OVERLOAD = 0.7; // seconds for overload pulse
|
|
91
|
+
|
|
92
|
+
// Reject particle constants
|
|
93
|
+
const REJECT_PARTICLE_R = PARTICLE_R;
|
|
94
|
+
const REJECT_DROP_DISTANCE = 30; // px downward travel
|
|
95
|
+
const REJECT_DURATION_MIN = 1.5; // seconds per drop at max reject
|
|
96
|
+
const REJECT_DURATION_MAX = 3; // seconds per drop at min reject
|
|
97
|
+
const REJECT_COUNT_MIN = 1;
|
|
98
|
+
const REJECT_COUNT_MAX = 3;
|
|
99
|
+
|
|
100
|
+
// ============================================================
|
|
101
|
+
// Edge path generator
|
|
102
|
+
// ============================================================
|
|
103
|
+
|
|
104
|
+
const lineGenerator = d3Shape.line<{ x: number; y: number }>()
|
|
105
|
+
.x((d) => d.x)
|
|
106
|
+
.y((d) => d.y)
|
|
107
|
+
.curve(d3Shape.curveBasis);
|
|
108
|
+
|
|
109
|
+
/** Compute the point on a node's border closest to an external target point. */
|
|
110
|
+
function nodeBorderPoint(
|
|
111
|
+
node: InfraLayoutNode,
|
|
112
|
+
target: { x: number; y: number },
|
|
113
|
+
): { x: number; y: number } {
|
|
114
|
+
const hw = node.width / 2;
|
|
115
|
+
const hh = node.height / 2;
|
|
116
|
+
const dx = target.x - node.x;
|
|
117
|
+
const dy = target.y - node.y;
|
|
118
|
+
if (dx === 0 && dy === 0) return { x: node.x + hw, y: node.y };
|
|
119
|
+
|
|
120
|
+
// Scale factor to reach the bounding box edge
|
|
121
|
+
const sx = hw / Math.abs(dx || 1);
|
|
122
|
+
const sy = hh / Math.abs(dy || 1);
|
|
123
|
+
const s = Math.min(sx, sy);
|
|
124
|
+
|
|
125
|
+
return { x: node.x + dx * s, y: node.y + dy * s };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Map RPS to animation duration — higher RPS = faster (shorter duration). */
|
|
129
|
+
function flowDuration(rps: number, maxRps: number): number {
|
|
130
|
+
if (maxRps <= 0) return FLOW_SPEED_MAX;
|
|
131
|
+
const t = Math.min(rps / maxRps, 1);
|
|
132
|
+
return FLOW_SPEED_MAX - t * (FLOW_SPEED_MAX - FLOW_SPEED_MIN);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Map RPS to particle count — more traffic = more particles. */
|
|
136
|
+
function particleCount(rps: number, maxRps: number): number {
|
|
137
|
+
if (maxRps <= 0) return PARTICLE_COUNT_MIN;
|
|
138
|
+
const t = Math.min(rps / maxRps, 1);
|
|
139
|
+
return Math.round(PARTICLE_COUNT_MIN + t * (PARTICLE_COUNT_MAX - PARTICLE_COUNT_MIN));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Determine if a node is in warning state (>70% capacity but not overloaded). */
|
|
143
|
+
function isWarning(node: InfraLayoutNode): boolean {
|
|
144
|
+
if (node.overloaded || node.isEdge) return false;
|
|
145
|
+
// Serverless: concurrency-based capacity
|
|
146
|
+
const concurrencyProp = node.properties.find((p) => p.key === 'concurrency');
|
|
147
|
+
if (concurrencyProp) {
|
|
148
|
+
const concurrency = Number(concurrencyProp.value);
|
|
149
|
+
const durationProp = node.properties.find((p) => p.key === 'duration-ms');
|
|
150
|
+
const durationMs = durationProp ? Number(durationProp.value) : 100;
|
|
151
|
+
const cap = concurrency / (durationMs / 1000);
|
|
152
|
+
return cap > 0 && node.computedRps / cap > 0.7;
|
|
153
|
+
}
|
|
154
|
+
// Traditional: max-rps * instances
|
|
155
|
+
const maxRps = node.properties.find((p) => p.key === 'max-rps');
|
|
156
|
+
if (!maxRps) return false;
|
|
157
|
+
const cap = Number(maxRps.value) * node.computedInstances;
|
|
158
|
+
return cap > 0 && node.computedRps / cap > 0.7;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============================================================
|
|
162
|
+
// Helpers
|
|
163
|
+
// ============================================================
|
|
164
|
+
|
|
165
|
+
/** Display names for behavior property keys. */
|
|
166
|
+
const PROP_DISPLAY: Record<string, string> = {
|
|
167
|
+
'cache-hit': 'cache hit',
|
|
168
|
+
'firewall-block': 'fw block',
|
|
169
|
+
'ratelimit-rps': 'rate limit',
|
|
170
|
+
'latency-ms': 'latency',
|
|
171
|
+
'uptime': 'uptime',
|
|
172
|
+
'instances': 'instances',
|
|
173
|
+
'max-rps': 'capacity',
|
|
174
|
+
'cb-error-threshold': 'CB error',
|
|
175
|
+
'cb-latency-threshold-ms': 'CB latency',
|
|
176
|
+
'concurrency': 'concurrency',
|
|
177
|
+
'duration-ms': 'duration',
|
|
178
|
+
'cold-start-ms': 'cold start',
|
|
179
|
+
'buffer': 'buffer',
|
|
180
|
+
'drain-rate': 'drain',
|
|
181
|
+
'retention-hours': 'retention',
|
|
182
|
+
'partitions': 'partitions',
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Keys whose values are RPS counts and should be formatted like RPS. */
|
|
186
|
+
const RPS_FORMAT_KEYS = new Set(['max-rps', 'ratelimit-rps']);
|
|
187
|
+
|
|
188
|
+
/** Keys whose values are milliseconds and should show the "ms" suffix. */
|
|
189
|
+
const MS_FORMAT_KEYS = new Set(['latency-ms', 'cb-latency-threshold-ms', 'duration-ms', 'cold-start-ms']);
|
|
190
|
+
|
|
191
|
+
/** Keys whose values are percentages and should show the "%" suffix. */
|
|
192
|
+
const PCT_FORMAT_KEYS = new Set(['cache-hit', 'firewall-block', 'uptime', 'cb-error-threshold']);
|
|
193
|
+
|
|
194
|
+
/** Computed metric rows (latency percentiles, uptime, availability, CB state) shown after declared props. */
|
|
195
|
+
function getComputedRows(node: InfraLayoutNode, expanded: boolean): ComputedRow[] {
|
|
196
|
+
const rows: ComputedRow[] = [];
|
|
197
|
+
|
|
198
|
+
// Serverless instances: demand vs concurrency limit
|
|
199
|
+
if (node.computedConcurrentInvocations > 0) {
|
|
200
|
+
const concurrency = getNodeNumProp(node, 'concurrency', 0);
|
|
201
|
+
const demand = node.computedConcurrentInvocations;
|
|
202
|
+
const ratio = concurrency > 0 ? demand / concurrency : 0;
|
|
203
|
+
const color = ratio > 1 ? COLOR_OVERLOADED : ratio > 0.7 ? COLOR_WARNING : undefined;
|
|
204
|
+
const value = concurrency > 0
|
|
205
|
+
? `${formatCount(demand)} / ${formatCount(concurrency)}`
|
|
206
|
+
: `${formatCount(demand)}`;
|
|
207
|
+
rows.push({ key: 'instances', value, color, inverted: color != null });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const p = node.computedLatencyPercentiles;
|
|
211
|
+
if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) {
|
|
212
|
+
if (expanded) {
|
|
213
|
+
rows.push({ key: 'p50', value: formatMsShort(p.p50) });
|
|
214
|
+
rows.push({ key: 'p90', value: formatMsShort(p.p90) });
|
|
215
|
+
}
|
|
216
|
+
rows.push({ key: 'p99', value: formatMsShort(p.p99) });
|
|
217
|
+
}
|
|
218
|
+
// Computed (cumulative) uptime — only show when it differs from the declared node uptime.
|
|
219
|
+
// On the edge node this is the system-wide uptime; on other nodes it's the path product.
|
|
220
|
+
// Label as "eff. uptime" to distinguish from the declared "uptime" property row.
|
|
221
|
+
if (node.computedUptime < 1) {
|
|
222
|
+
const declaredUptime = node.properties.find((p) => p.key === 'uptime');
|
|
223
|
+
const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
|
|
224
|
+
const differs = Math.abs(node.computedUptime - declaredVal) > 0.000001;
|
|
225
|
+
if (differs || node.isEdge) {
|
|
226
|
+
rows.push({ key: 'eff. uptime', value: formatUptimeShort(node.computedUptime) });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (node.computedAvailability < 1) {
|
|
230
|
+
const color = node.computedAvailability < 0.95 ? COLOR_OVERLOADED
|
|
231
|
+
: node.computedAvailability < 0.99 ? COLOR_WARNING
|
|
232
|
+
: undefined;
|
|
233
|
+
rows.push({ key: 'availability', value: formatUptimeShort(node.computedAvailability), color, inverted: color != null });
|
|
234
|
+
}
|
|
235
|
+
// Circuit breaker state — show when a CB is configured and open
|
|
236
|
+
if (node.computedCbState === 'open') {
|
|
237
|
+
rows.push({ key: 'CB', value: 'OPEN', color: COLOR_OVERLOADED, inverted: true });
|
|
238
|
+
}
|
|
239
|
+
// Queue computed rows: lag and overflow
|
|
240
|
+
if (node.queueMetrics) {
|
|
241
|
+
const { fillRate, timeToOverflow } = node.queueMetrics;
|
|
242
|
+
if (fillRate > 0) {
|
|
243
|
+
rows.push({ key: 'lag', value: `${formatCount(Math.round(fillRate))} msg/s` });
|
|
244
|
+
}
|
|
245
|
+
if (fillRate > 0 && timeToOverflow < Infinity) {
|
|
246
|
+
const overflowColor = timeToOverflow < 60 ? COLOR_OVERLOADED : COLOR_WARNING;
|
|
247
|
+
rows.push({
|
|
248
|
+
key: 'overflow',
|
|
249
|
+
value: `~${Math.round(timeToOverflow)}s`,
|
|
250
|
+
color: overflowColor,
|
|
251
|
+
inverted: timeToOverflow < 60,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function formatCount(n: number): string {
|
|
259
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
|
|
260
|
+
if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
|
|
261
|
+
return String(n);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function formatMsShort(ms: number): string {
|
|
265
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
266
|
+
return `${Math.round(ms)}ms`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function formatUptimeShort(fraction: number): string {
|
|
270
|
+
const pct = fraction * 100;
|
|
271
|
+
if (pct >= 99.99) return '99.99%';
|
|
272
|
+
if (pct >= 99.9) return `${pct.toFixed(2)}%`;
|
|
273
|
+
if (pct >= 99) return `${pct.toFixed(1)}%`;
|
|
274
|
+
return `${pct.toFixed(1)}%`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Properties shown as key-value rows inside the node card. */
|
|
278
|
+
function getDisplayProps(node: InfraLayoutNode, expanded: boolean, diagramOptions?: Record<string, string>): { key: string; displayKey: string; value: string }[] {
|
|
279
|
+
if (node.isEdge) return [];
|
|
280
|
+
const rows: { key: string; displayKey: string; value: string }[] = [];
|
|
281
|
+
for (const p of node.properties) {
|
|
282
|
+
const displayKey = PROP_DISPLAY[p.key];
|
|
283
|
+
if (!displayKey) continue;
|
|
284
|
+
// Rate limit and max-rps are already shown in the RPS denominator —
|
|
285
|
+
// only show the dedicated row when the node is expanded (selected).
|
|
286
|
+
if (p.key === 'ratelimit-rps' && !expanded) continue;
|
|
287
|
+
if (p.key === 'max-rps' && !expanded) continue;
|
|
288
|
+
if (p.key === 'concurrency' && !expanded) continue;
|
|
289
|
+
// Format values with appropriate units
|
|
290
|
+
if (RPS_FORMAT_KEYS.has(p.key)) {
|
|
291
|
+
const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
292
|
+
rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatRpsShort(num) });
|
|
293
|
+
} else if (MS_FORMAT_KEYS.has(p.key)) {
|
|
294
|
+
const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
295
|
+
rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatMsShort(num) });
|
|
296
|
+
} else if (PCT_FORMAT_KEYS.has(p.key)) {
|
|
297
|
+
const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
298
|
+
rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${num}%` });
|
|
299
|
+
} else if (p.key === 'buffer') {
|
|
300
|
+
const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
301
|
+
rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : formatCount(num) });
|
|
302
|
+
} else if (p.key === 'drain-rate') {
|
|
303
|
+
const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
304
|
+
rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${formatRpsShort(num)}/s` });
|
|
305
|
+
} else if (p.key === 'retention-hours') {
|
|
306
|
+
const num = typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
307
|
+
rows.push({ key: p.key, displayKey, value: isNaN(num) ? String(p.value) : `${num}h` });
|
|
308
|
+
} else {
|
|
309
|
+
rows.push({ key: p.key, displayKey, value: String(p.value) });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Inject diagram-level defaults for properties the node doesn't explicitly declare
|
|
313
|
+
if (diagramOptions) {
|
|
314
|
+
const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
|
|
315
|
+
const hasUptime = node.properties.some((p) => p.key === 'uptime');
|
|
316
|
+
const isServerlessNode = node.properties.some((p) => p.key === 'concurrency');
|
|
317
|
+
const defaultLatency = parseFloat(diagramOptions['default-latency-ms'] ?? '') || 0;
|
|
318
|
+
const defaultUptime = parseFloat(diagramOptions['default-uptime'] ?? '') || 0;
|
|
319
|
+
if (!hasLatency && !isServerlessNode && defaultLatency > 0) {
|
|
320
|
+
rows.push({ key: 'latency-ms', displayKey: 'latency', value: formatMsShort(defaultLatency) });
|
|
321
|
+
}
|
|
322
|
+
if (!hasUptime && defaultUptime > 0 && defaultUptime < 100) {
|
|
323
|
+
rows.push({ key: 'uptime', displayKey: 'uptime', value: `${defaultUptime}%` });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return rows;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function formatRps(rps: number): string {
|
|
330
|
+
if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k rps`;
|
|
331
|
+
return `${Math.round(rps)} rps`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** RPS value without "rps" suffix — for key-value rows where the key already says "RPS". */
|
|
335
|
+
function formatRpsShort(rps: number): string {
|
|
336
|
+
if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k`;
|
|
337
|
+
return `${Math.round(rps)}`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Compute the worst severity across all row-producing conditions for a node.
|
|
341
|
+
* Returns 'overloaded' (red), 'warning' (yellow), or 'normal'. */
|
|
342
|
+
function worstNodeSeverity(node: InfraLayoutNode): 'overloaded' | 'warning' | 'normal' {
|
|
343
|
+
let worst: 'overloaded' | 'warning' | 'normal' = 'normal';
|
|
344
|
+
const upgrade = (s: 'overloaded' | 'warning') => {
|
|
345
|
+
if (s === 'overloaded') worst = 'overloaded';
|
|
346
|
+
else if (worst !== 'overloaded') worst = 'warning';
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// Collapsed group nodes: incorporate child health state
|
|
350
|
+
if (node.childHealthState === 'overloaded') upgrade('overloaded');
|
|
351
|
+
else if (node.childHealthState === 'warning') upgrade('warning');
|
|
352
|
+
|
|
353
|
+
// RPS-based: overloaded, rate-limited, or >70% capacity
|
|
354
|
+
if (node.overloaded) upgrade('overloaded');
|
|
355
|
+
if (node.rateLimited) upgrade('warning');
|
|
356
|
+
if (isWarning(node)) upgrade('warning');
|
|
357
|
+
|
|
358
|
+
// Availability
|
|
359
|
+
if (node.computedAvailability < 0.95) upgrade('overloaded');
|
|
360
|
+
else if (node.computedAvailability < 0.99) upgrade('warning');
|
|
361
|
+
|
|
362
|
+
// Circuit breaker open
|
|
363
|
+
if (node.computedCbState === 'open') upgrade('overloaded');
|
|
364
|
+
|
|
365
|
+
// Queue state: filling → warning, imminent overflow → overloaded
|
|
366
|
+
if (node.queueMetrics && node.queueMetrics.fillRate > 0) {
|
|
367
|
+
if (node.queueMetrics.timeToOverflow < 60) upgrade('overloaded');
|
|
368
|
+
else upgrade('warning');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Rate-limit row (pre-rate-limit RPS vs configured limit)
|
|
372
|
+
if (!node.isEdge) {
|
|
373
|
+
const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
|
|
374
|
+
if (nodeRateLimit > 0 && node.computedRps > 0) {
|
|
375
|
+
let preRl = node.computedRps;
|
|
376
|
+
const ch = getNodeNumProp(node, 'cache-hit', 0);
|
|
377
|
+
if (ch > 0) preRl *= (100 - ch) / 100;
|
|
378
|
+
const fw = getNodeNumProp(node, 'firewall-block', 0);
|
|
379
|
+
if (fw > 0) preRl *= (100 - fw) / 100;
|
|
380
|
+
if (preRl > nodeRateLimit) upgrade('overloaded');
|
|
381
|
+
else if (preRl > nodeRateLimit * 0.8) upgrade('warning');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return worst;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function nodeColor(node: InfraLayoutNode, palette: PaletteColors, isDark: boolean): {
|
|
389
|
+
fill: string;
|
|
390
|
+
stroke: string;
|
|
391
|
+
textFill: string;
|
|
392
|
+
} {
|
|
393
|
+
const severity = worstNodeSeverity(node);
|
|
394
|
+
if (severity === 'overloaded') {
|
|
395
|
+
return {
|
|
396
|
+
fill: mix(palette.bg, COLOR_OVERLOADED, isDark ? 80 : 92),
|
|
397
|
+
stroke: COLOR_OVERLOADED,
|
|
398
|
+
textFill: palette.text,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (severity === 'warning') {
|
|
402
|
+
return {
|
|
403
|
+
fill: mix(palette.bg, COLOR_WARNING, isDark ? 85 : 92),
|
|
404
|
+
stroke: COLOR_WARNING,
|
|
405
|
+
textFill: palette.text,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
fill: isDark ? mix(palette.bg, palette.text, 90) : mix(palette.bg, palette.text, 95),
|
|
410
|
+
stroke: isDark ? mix(palette.text, palette.bg, 60) : mix(palette.text, palette.bg, 40),
|
|
411
|
+
textFill: palette.text,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function edgeColor(_edge: InfraLayoutEdge, palette: PaletteColors): string {
|
|
416
|
+
return palette.textMuted;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function edgeWidth(): number {
|
|
420
|
+
return EDGE_STROKE_WIDTH;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ============================================================
|
|
424
|
+
// Render functions
|
|
425
|
+
// ============================================================
|
|
426
|
+
|
|
427
|
+
function renderGroups(
|
|
428
|
+
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
429
|
+
groups: InfraLayoutGroup[],
|
|
430
|
+
palette: PaletteColors,
|
|
431
|
+
isDark: boolean,
|
|
432
|
+
) {
|
|
433
|
+
for (const group of groups) {
|
|
434
|
+
const g = svg.append('g')
|
|
435
|
+
.attr('class', 'infra-group')
|
|
436
|
+
.attr('data-line-number', group.lineNumber)
|
|
437
|
+
.attr('data-node-toggle', group.id)
|
|
438
|
+
.style('cursor', 'pointer');
|
|
439
|
+
|
|
440
|
+
// Filled background (matching org chart container style)
|
|
441
|
+
const groupFill = mix(palette.surface, palette.bg, 40);
|
|
442
|
+
const groupStroke = palette.textMuted;
|
|
443
|
+
g.append('rect')
|
|
444
|
+
.attr('x', group.x)
|
|
445
|
+
.attr('y', group.y)
|
|
446
|
+
.attr('width', group.width)
|
|
447
|
+
.attr('height', group.height)
|
|
448
|
+
.attr('rx', 6)
|
|
449
|
+
.attr('fill', groupFill)
|
|
450
|
+
.attr('stroke', groupStroke)
|
|
451
|
+
.attr('stroke-opacity', 0.35)
|
|
452
|
+
.attr('stroke-width', 1);
|
|
453
|
+
|
|
454
|
+
// Group label (centered at top)
|
|
455
|
+
g.append('text')
|
|
456
|
+
.attr('x', group.x + group.width / 2)
|
|
457
|
+
.attr('y', group.y + 16)
|
|
458
|
+
.attr('text-anchor', 'middle')
|
|
459
|
+
.attr('font-family', FONT_FAMILY)
|
|
460
|
+
.attr('font-size', GROUP_LABEL_FONT_SIZE)
|
|
461
|
+
.attr('font-weight', '600')
|
|
462
|
+
.attr('fill', palette.text)
|
|
463
|
+
.text(group.label);
|
|
464
|
+
|
|
465
|
+
// Group instances badge (top-right, like node instance badges)
|
|
466
|
+
const gi = typeof group.instances === 'number' ? group.instances :
|
|
467
|
+
typeof group.instances === 'string' ? parseInt(String(group.instances), 10) || 0 : 0;
|
|
468
|
+
if (gi > 1) {
|
|
469
|
+
g.append('text')
|
|
470
|
+
.attr('x', group.x + group.width - 8)
|
|
471
|
+
.attr('y', group.y + 16)
|
|
472
|
+
.attr('text-anchor', 'end')
|
|
473
|
+
.attr('font-family', FONT_FAMILY)
|
|
474
|
+
.attr('font-size', META_FONT_SIZE)
|
|
475
|
+
.attr('fill', palette.textMuted)
|
|
476
|
+
.text(`${gi}x`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function renderEdgePaths(
|
|
482
|
+
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
483
|
+
edges: InfraLayoutEdge[],
|
|
484
|
+
nodes: InfraLayoutNode[],
|
|
485
|
+
palette: PaletteColors,
|
|
486
|
+
isDark: boolean,
|
|
487
|
+
animate: boolean,
|
|
488
|
+
) {
|
|
489
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
490
|
+
const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
|
|
491
|
+
|
|
492
|
+
for (const edge of edges) {
|
|
493
|
+
if (edge.points.length === 0) continue;
|
|
494
|
+
|
|
495
|
+
const targetNode = nodeMap.get(edge.targetId);
|
|
496
|
+
const sourceNode = nodeMap.get(edge.sourceId);
|
|
497
|
+
const color = edgeColor(edge, palette);
|
|
498
|
+
const strokeW = edgeWidth();
|
|
499
|
+
|
|
500
|
+
// Ensure dagre waypoints are ordered source→target (not guaranteed by dagre)
|
|
501
|
+
let pts = edge.points;
|
|
502
|
+
if (sourceNode && targetNode && pts.length >= 2) {
|
|
503
|
+
const first = pts[0];
|
|
504
|
+
const distFirstToSource = (first.x - sourceNode.x) ** 2 + (first.y - sourceNode.y) ** 2;
|
|
505
|
+
const distFirstToTarget = (first.x - targetNode.x) ** 2 + (first.y - targetNode.y) ** 2;
|
|
506
|
+
if (distFirstToTarget < distFirstToSource) {
|
|
507
|
+
pts = [...pts].reverse();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Prepend source border point and append target border point so edges
|
|
512
|
+
// visually connect to node boundaries (dagre waypoints float between nodes)
|
|
513
|
+
if (sourceNode && pts.length > 0) {
|
|
514
|
+
const bp = nodeBorderPoint(sourceNode, pts[0]);
|
|
515
|
+
pts = [bp, ...pts];
|
|
516
|
+
}
|
|
517
|
+
if (targetNode && pts.length > 0) {
|
|
518
|
+
const bp = nodeBorderPoint(targetNode, pts[pts.length - 1]);
|
|
519
|
+
pts = [...pts, bp];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const pathD = lineGenerator(pts) ?? '';
|
|
523
|
+
const edgeG = svg.append('g')
|
|
524
|
+
.attr('class', 'infra-edge')
|
|
525
|
+
.attr('data-line-number', edge.lineNumber);
|
|
526
|
+
|
|
527
|
+
edgeG.append('path')
|
|
528
|
+
.attr('d', pathD)
|
|
529
|
+
.attr('fill', 'none')
|
|
530
|
+
.attr('stroke', color)
|
|
531
|
+
.attr('stroke-width', strokeW);
|
|
532
|
+
|
|
533
|
+
if (animate && edge.computedRps > 0) {
|
|
534
|
+
const dur = flowDuration(edge.computedRps, maxRps);
|
|
535
|
+
|
|
536
|
+
// Particles traveling along the path — always green (overloaded nodes
|
|
537
|
+
// already have red styling + reject particles to show the problem)
|
|
538
|
+
const count = particleCount(edge.computedRps, maxRps);
|
|
539
|
+
const particleColor = palette.colors.green;
|
|
540
|
+
|
|
541
|
+
for (let i = 0; i < count; i++) {
|
|
542
|
+
const delay = (dur / count) * i;
|
|
543
|
+
const circle = edgeG.append('circle')
|
|
544
|
+
.attr('r', PARTICLE_R)
|
|
545
|
+
.attr('fill', particleColor)
|
|
546
|
+
.attr('opacity', 0.85);
|
|
547
|
+
|
|
548
|
+
// Use SMIL <animateMotion> for path-following
|
|
549
|
+
circle.append('animateMotion')
|
|
550
|
+
.attr('dur', `${dur}s`)
|
|
551
|
+
.attr('repeatCount', 'indefinite')
|
|
552
|
+
.attr('begin', `${delay}s`)
|
|
553
|
+
.attr('path', pathD);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function renderEdgeLabels(
|
|
560
|
+
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
561
|
+
edges: InfraLayoutEdge[],
|
|
562
|
+
palette: PaletteColors,
|
|
563
|
+
isDark: boolean,
|
|
564
|
+
animate: boolean,
|
|
565
|
+
) {
|
|
566
|
+
for (const edge of edges) {
|
|
567
|
+
if (edge.points.length === 0) continue;
|
|
568
|
+
if (!edge.label) continue;
|
|
569
|
+
|
|
570
|
+
const midIdx = Math.floor(edge.points.length / 2);
|
|
571
|
+
const midPt = edge.points[midIdx];
|
|
572
|
+
const labelText = edge.label;
|
|
573
|
+
|
|
574
|
+
const g = svg.append('g')
|
|
575
|
+
.attr('class', animate ? 'infra-edge-label' : '');
|
|
576
|
+
|
|
577
|
+
// Background rect for readability
|
|
578
|
+
const textWidth = labelText.length * 6.5 + 8;
|
|
579
|
+
g.append('rect')
|
|
580
|
+
.attr('x', midPt.x - textWidth / 2)
|
|
581
|
+
.attr('y', midPt.y - 8)
|
|
582
|
+
.attr('width', textWidth)
|
|
583
|
+
.attr('height', 16)
|
|
584
|
+
.attr('rx', 3)
|
|
585
|
+
.attr('fill', palette.bg)
|
|
586
|
+
.attr('opacity', 0.9);
|
|
587
|
+
|
|
588
|
+
g.append('text')
|
|
589
|
+
.attr('x', midPt.x)
|
|
590
|
+
.attr('y', midPt.y + 4)
|
|
591
|
+
.attr('text-anchor', 'middle')
|
|
592
|
+
.attr('font-family', FONT_FAMILY)
|
|
593
|
+
.attr('font-size', EDGE_LABEL_FONT_SIZE)
|
|
594
|
+
.attr('fill', palette.textMuted)
|
|
595
|
+
.text(labelText);
|
|
596
|
+
|
|
597
|
+
// When animated, add a wider invisible hover zone so labels appear on hover
|
|
598
|
+
if (animate) {
|
|
599
|
+
const pathD = lineGenerator(edge.points) ?? '';
|
|
600
|
+
g.insert('path', ':first-child')
|
|
601
|
+
.attr('d', pathD)
|
|
602
|
+
.attr('fill', 'none')
|
|
603
|
+
.attr('stroke', 'transparent')
|
|
604
|
+
.attr('stroke-width', 20);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Returns the resolved tag color for a node's active tag group, or null if not applicable. */
|
|
610
|
+
function resolveActiveTagStroke(
|
|
611
|
+
node: InfraLayoutNode,
|
|
612
|
+
activeGroup: string,
|
|
613
|
+
tagGroups: InfraTagGroup[],
|
|
614
|
+
palette: PaletteColors,
|
|
615
|
+
): string | null {
|
|
616
|
+
const tg = tagGroups.find((t) => t.name.toLowerCase() === activeGroup.toLowerCase());
|
|
617
|
+
if (!tg) return null;
|
|
618
|
+
const tagKey = (tg.alias ?? tg.name).toLowerCase();
|
|
619
|
+
const tagVal = node.tags[tagKey];
|
|
620
|
+
if (!tagVal) return null;
|
|
621
|
+
const tv = tg.values.find((v) => v.name.toLowerCase() === tagVal.toLowerCase());
|
|
622
|
+
if (!tv?.color) return null;
|
|
623
|
+
return resolveColor(tv.color, palette);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function renderNodes(
|
|
627
|
+
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
628
|
+
nodes: InfraLayoutNode[],
|
|
629
|
+
palette: PaletteColors,
|
|
630
|
+
isDark: boolean,
|
|
631
|
+
animate: boolean,
|
|
632
|
+
selectedNodeId?: string | null,
|
|
633
|
+
activeGroup?: string | null,
|
|
634
|
+
diagramOptions?: Record<string, string>,
|
|
635
|
+
collapsedNodes?: Set<string> | null,
|
|
636
|
+
tagGroups?: InfraTagGroup[],
|
|
637
|
+
) {
|
|
638
|
+
const mutedColor = palette.textMuted;
|
|
639
|
+
|
|
640
|
+
for (const node of nodes) {
|
|
641
|
+
let { fill, stroke, textFill } = nodeColor(node, palette, isDark);
|
|
642
|
+
|
|
643
|
+
// When a tag legend is active, override border color with tag color
|
|
644
|
+
if (activeGroup && tagGroups && !node.isEdge) {
|
|
645
|
+
const tagStroke = resolveActiveTagStroke(node, activeGroup, tagGroups, palette);
|
|
646
|
+
if (tagStroke) {
|
|
647
|
+
stroke = tagStroke;
|
|
648
|
+
fill = mix(palette.bg, tagStroke, isDark ? 88 : 94);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
let cls = 'infra-node';
|
|
652
|
+
if (animate && node.isEdge) {
|
|
653
|
+
cls += ' infra-node-edge-throb';
|
|
654
|
+
} else if (animate && !node.isEdge) {
|
|
655
|
+
const severity = worstNodeSeverity(node);
|
|
656
|
+
if (node.computedCbState === 'open') cls += ' infra-node-cb-open';
|
|
657
|
+
else if (severity === 'overloaded') cls += ' infra-node-overload';
|
|
658
|
+
else if (severity === 'warning') cls += ' infra-node-warning';
|
|
659
|
+
}
|
|
660
|
+
const g = svg.append('g')
|
|
661
|
+
.attr('class', cls)
|
|
662
|
+
.attr('data-line-number', node.lineNumber)
|
|
663
|
+
.attr('data-infra-node', node.id)
|
|
664
|
+
.attr('data-node-collapse', node.id)
|
|
665
|
+
.style('cursor', 'pointer');
|
|
666
|
+
|
|
667
|
+
// Collapsed group nodes: toggle attribute
|
|
668
|
+
if (node.id.startsWith('[')) {
|
|
669
|
+
g.attr('data-node-toggle', node.id);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Expose tag values for legend hover dimming
|
|
673
|
+
for (const [tagKey, tagVal] of Object.entries(node.tags)) {
|
|
674
|
+
g.attr(`data-tag-${tagKey.toLowerCase()}`, tagVal.toLowerCase());
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Expose role names for role legend hover dimming
|
|
678
|
+
if (!node.isEdge) {
|
|
679
|
+
const roles = inferRoles(node.properties);
|
|
680
|
+
for (const role of roles) {
|
|
681
|
+
g.attr(`data-role-${role.name.toLowerCase().replace(/\s+/g, '-')}`, 'true');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const x = node.x - node.width / 2;
|
|
686
|
+
const y = node.y - node.height / 2;
|
|
687
|
+
const isCollapsedGroup = node.id.startsWith('[');
|
|
688
|
+
const strokeWidth = worstNodeSeverity(node) !== 'normal' ? OVERLOAD_STROKE_WIDTH : NODE_STROKE_WIDTH;
|
|
689
|
+
|
|
690
|
+
// Node rect
|
|
691
|
+
g.append('rect')
|
|
692
|
+
.attr('x', x)
|
|
693
|
+
.attr('y', y)
|
|
694
|
+
.attr('width', node.width)
|
|
695
|
+
.attr('height', node.height)
|
|
696
|
+
.attr('rx', NODE_BORDER_RADIUS)
|
|
697
|
+
.attr('fill', fill)
|
|
698
|
+
.attr('stroke', stroke)
|
|
699
|
+
.attr('stroke-width', strokeWidth);
|
|
700
|
+
|
|
701
|
+
// Node name — centered in header area
|
|
702
|
+
const headerCenterY = y + NODE_HEADER_HEIGHT / 2 + NODE_FONT_SIZE * 0.35;
|
|
703
|
+
g.append('text')
|
|
704
|
+
.attr('x', node.x)
|
|
705
|
+
.attr('y', headerCenterY)
|
|
706
|
+
.attr('text-anchor', 'middle')
|
|
707
|
+
.attr('font-family', FONT_FAMILY)
|
|
708
|
+
.attr('font-size', NODE_FONT_SIZE)
|
|
709
|
+
.attr('font-weight', '600')
|
|
710
|
+
.attr('fill', textFill)
|
|
711
|
+
.text(node.label);
|
|
712
|
+
|
|
713
|
+
// --- Key-value rows below header (skipped for collapsed nodes) ---
|
|
714
|
+
const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
|
|
715
|
+
if (isNodeCollapsed) {
|
|
716
|
+
// Collapsed: show a subtle chevron indicator at the bottom of the header
|
|
717
|
+
const chevronY = y + node.height - 6;
|
|
718
|
+
g.append('text')
|
|
719
|
+
.attr('x', node.x)
|
|
720
|
+
.attr('y', chevronY)
|
|
721
|
+
.attr('text-anchor', 'middle')
|
|
722
|
+
.attr('font-family', FONT_FAMILY)
|
|
723
|
+
.attr('font-size', 8)
|
|
724
|
+
.attr('fill', textFill)
|
|
725
|
+
.attr('opacity', 0.5)
|
|
726
|
+
.text('▼');
|
|
727
|
+
}
|
|
728
|
+
if (!isNodeCollapsed) {
|
|
729
|
+
const expanded = node.id === selectedNodeId;
|
|
730
|
+
// Declared properties only shown when node is selected (expanded)
|
|
731
|
+
const displayProps = (!node.isEdge && expanded) ? getDisplayProps(node, expanded, diagramOptions) : [];
|
|
732
|
+
const computedRows = getComputedRows(node, expanded);
|
|
733
|
+
const hasContent = displayProps.length > 0 || computedRows.length > 0 || node.computedRps > 0;
|
|
734
|
+
|
|
735
|
+
if (hasContent) {
|
|
736
|
+
// Separator line between header and body
|
|
737
|
+
const sepY = y + NODE_HEADER_HEIGHT;
|
|
738
|
+
g.append('line')
|
|
739
|
+
.attr('x1', x)
|
|
740
|
+
.attr('y1', sepY)
|
|
741
|
+
.attr('x2', x + node.width)
|
|
742
|
+
.attr('y2', sepY)
|
|
743
|
+
.attr('stroke', stroke)
|
|
744
|
+
.attr('stroke-opacity', 0.3)
|
|
745
|
+
.attr('stroke-width', 1);
|
|
746
|
+
|
|
747
|
+
// Build rows in two sections: computed (dynamic) on top, declared (config) below.
|
|
748
|
+
// A second separator line divides them when both sections have content.
|
|
749
|
+
const computedSection: NodeRow[] = [];
|
|
750
|
+
const declaredSection: NodeRow[] = [];
|
|
751
|
+
|
|
752
|
+
// Determine effective throughput limit for the RPS row denominator
|
|
753
|
+
const nodeMaxRps = getNodeNumProp(node, 'max-rps', 0);
|
|
754
|
+
const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
|
|
755
|
+
const nodeConcurrency = getNodeNumProp(node, 'concurrency', 0);
|
|
756
|
+
const nodeDurationMs = getNodeNumProp(node, 'duration-ms', 100);
|
|
757
|
+
const serverlessCap = nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
|
|
758
|
+
const effectiveCap = serverlessCap > 0 ? serverlessCap
|
|
759
|
+
: nodeMaxRps > 0 && nodeRateLimit > 0
|
|
760
|
+
? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
|
|
761
|
+
: nodeMaxRps > 0 ? nodeMaxRps * node.computedInstances
|
|
762
|
+
: nodeRateLimit > 0 ? nodeRateLimit
|
|
763
|
+
: 0;
|
|
764
|
+
|
|
765
|
+
// --- Computed section: RPS + computed metrics ---
|
|
766
|
+
if (node.computedRps > 0) {
|
|
767
|
+
// Check rate-limit proximity (pre-ratelimit RPS after cache/fw reductions)
|
|
768
|
+
let rlSeverity: 'overloaded' | 'warning' | 'normal' = 'normal';
|
|
769
|
+
if (nodeRateLimit > 0 && node.computedRps > 0 && !node.isEdge) {
|
|
770
|
+
let preRl = node.computedRps;
|
|
771
|
+
const ch = getNodeNumProp(node, 'cache-hit', 0);
|
|
772
|
+
if (ch > 0) preRl *= (100 - ch) / 100;
|
|
773
|
+
const fw = getNodeNumProp(node, 'firewall-block', 0);
|
|
774
|
+
if (fw > 0) preRl *= (100 - fw) / 100;
|
|
775
|
+
if (preRl > nodeRateLimit) rlSeverity = 'overloaded';
|
|
776
|
+
else if (preRl > nodeRateLimit * 0.8) rlSeverity = 'warning';
|
|
777
|
+
}
|
|
778
|
+
const rpsSeverity: 'overloaded' | 'warning' | 'normal' =
|
|
779
|
+
node.overloaded ? 'overloaded'
|
|
780
|
+
: rlSeverity === 'overloaded' ? 'overloaded'
|
|
781
|
+
: node.rateLimited ? 'warning'
|
|
782
|
+
: isWarning(node) ? 'warning'
|
|
783
|
+
: rlSeverity === 'warning' ? 'warning'
|
|
784
|
+
: 'normal';
|
|
785
|
+
const rpsColor = rpsSeverity === 'overloaded' ? COLOR_OVERLOADED : rpsSeverity === 'warning' ? COLOR_WARNING : mutedColor;
|
|
786
|
+
const rpsInverted = rpsSeverity !== 'normal';
|
|
787
|
+
const rpsText = effectiveCap > 0 && !node.isEdge
|
|
788
|
+
? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
|
|
789
|
+
: formatRpsShort(node.computedRps);
|
|
790
|
+
computedSection.push({
|
|
791
|
+
key: 'RPS', value: rpsText, valueFill: rpsColor, fontWeight: '500',
|
|
792
|
+
inverted: rpsInverted, invertedBg: rpsInverted ? rpsColor : undefined,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
for (const cr of computedRows) {
|
|
796
|
+
computedSection.push({
|
|
797
|
+
key: cr.key, value: cr.value,
|
|
798
|
+
valueFill: cr.color ?? mutedColor,
|
|
799
|
+
fontWeight: 'normal',
|
|
800
|
+
inverted: cr.inverted,
|
|
801
|
+
invertedBg: cr.inverted ? cr.color : undefined,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// --- Declared section: user-defined properties (only when node is selected) ---
|
|
806
|
+
for (const prop of displayProps) {
|
|
807
|
+
let propColor = textFill;
|
|
808
|
+
let inverted = false;
|
|
809
|
+
let invertedBg: string | undefined;
|
|
810
|
+
if (prop.key === 'ratelimit-rps' && nodeRateLimit > 0 && node.computedRps > 0) {
|
|
811
|
+
let preRl = node.computedRps;
|
|
812
|
+
const ch = getNodeNumProp(node, 'cache-hit', 0);
|
|
813
|
+
if (ch > 0) preRl *= (100 - ch) / 100;
|
|
814
|
+
const fw = getNodeNumProp(node, 'firewall-block', 0);
|
|
815
|
+
if (fw > 0) preRl *= (100 - fw) / 100;
|
|
816
|
+
if (preRl > nodeRateLimit) { propColor = COLOR_OVERLOADED; inverted = true; invertedBg = COLOR_OVERLOADED; }
|
|
817
|
+
else if (preRl > nodeRateLimit * 0.8) { propColor = COLOR_WARNING; inverted = true; invertedBg = COLOR_WARNING; }
|
|
818
|
+
}
|
|
819
|
+
declaredSection.push({ key: prop.displayKey, value: prop.value, valueFill: propColor, fontWeight: 'normal', inverted, invertedBg });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const rows = [...computedSection, ...declaredSection];
|
|
823
|
+
|
|
824
|
+
// Compute max key width so values align vertically
|
|
825
|
+
const maxKeyLen = Math.max(...rows.map((r) => r.key.length));
|
|
826
|
+
const valueX = x + 10 + (maxKeyLen + 2) * (META_FONT_SIZE * 0.6);
|
|
827
|
+
|
|
828
|
+
let rowY = sepY + NODE_SEPARATOR_GAP + META_FONT_SIZE;
|
|
829
|
+
const needsSectionSep = computedSection.length > 0 && declaredSection.length > 0;
|
|
830
|
+
let rowIdx = 0;
|
|
831
|
+
for (const row of rows) {
|
|
832
|
+
// Draw separator line between computed and declared sections
|
|
833
|
+
if (needsSectionSep && rowIdx === computedSection.length) {
|
|
834
|
+
const sepLineY = rowY - META_FONT_SIZE + 1;
|
|
835
|
+
g.append('line')
|
|
836
|
+
.attr('x1', x)
|
|
837
|
+
.attr('y1', sepLineY)
|
|
838
|
+
.attr('x2', x + node.width)
|
|
839
|
+
.attr('y2', sepLineY)
|
|
840
|
+
.attr('stroke', stroke)
|
|
841
|
+
.attr('stroke-opacity', 0.3)
|
|
842
|
+
.attr('stroke-width', 1);
|
|
843
|
+
rowY += NODE_SEPARATOR_GAP;
|
|
844
|
+
}
|
|
845
|
+
if (row.inverted && row.invertedBg) {
|
|
846
|
+
// Inverted pill: colored background spanning full row width, white text, values still aligned
|
|
847
|
+
const pillH = META_LINE_HEIGHT - 1;
|
|
848
|
+
const pillPad = 4;
|
|
849
|
+
const pillX = x + pillPad;
|
|
850
|
+
const pillY = rowY - META_FONT_SIZE + 1;
|
|
851
|
+
const pillW = node.width - pillPad * 2;
|
|
852
|
+
g.append('rect')
|
|
853
|
+
.attr('x', pillX)
|
|
854
|
+
.attr('y', pillY)
|
|
855
|
+
.attr('width', pillW)
|
|
856
|
+
.attr('height', pillH)
|
|
857
|
+
.attr('rx', 3)
|
|
858
|
+
.attr('fill', row.invertedBg)
|
|
859
|
+
.attr('opacity', 0.9);
|
|
860
|
+
// Inverted text: use lightest available color for max contrast on colored pills
|
|
861
|
+
const pillTextColor = isDark ? '#ffffff' : palette.text;
|
|
862
|
+
// Key — same x as normal rows
|
|
863
|
+
g.append('text')
|
|
864
|
+
.attr('x', x + 10)
|
|
865
|
+
.attr('y', rowY)
|
|
866
|
+
.attr('font-family', FONT_FAMILY)
|
|
867
|
+
.attr('font-size', META_FONT_SIZE)
|
|
868
|
+
.attr('font-weight', '600')
|
|
869
|
+
.attr('fill', pillTextColor)
|
|
870
|
+
.text(`${row.key}: `);
|
|
871
|
+
// Value — aligned with other rows
|
|
872
|
+
g.append('text')
|
|
873
|
+
.attr('x', valueX)
|
|
874
|
+
.attr('y', rowY)
|
|
875
|
+
.attr('font-family', FONT_FAMILY)
|
|
876
|
+
.attr('font-size', META_FONT_SIZE)
|
|
877
|
+
.attr('font-weight', '600')
|
|
878
|
+
.attr('fill', pillTextColor)
|
|
879
|
+
.text(row.value);
|
|
880
|
+
} else {
|
|
881
|
+
// Normal row: muted key + colored value
|
|
882
|
+
g.append('text')
|
|
883
|
+
.attr('x', x + 10)
|
|
884
|
+
.attr('y', rowY)
|
|
885
|
+
.attr('font-family', FONT_FAMILY)
|
|
886
|
+
.attr('font-size', META_FONT_SIZE)
|
|
887
|
+
.attr('fill', mutedColor)
|
|
888
|
+
.text(`${row.key}: `);
|
|
889
|
+
|
|
890
|
+
g.append('text')
|
|
891
|
+
.attr('x', valueX)
|
|
892
|
+
.attr('y', rowY)
|
|
893
|
+
.attr('font-family', FONT_FAMILY)
|
|
894
|
+
.attr('font-size', META_FONT_SIZE)
|
|
895
|
+
.attr('font-weight', row.fontWeight)
|
|
896
|
+
.attr('fill', row.valueFill)
|
|
897
|
+
.text(row.value);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
rowY += META_LINE_HEIGHT;
|
|
901
|
+
rowIdx++;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Instance badge — clickable for interactive adjustment (not for edge or serverless nodes)
|
|
906
|
+
// Serverless nodes show instances in a computed row instead (demand / concurrency).
|
|
907
|
+
if (!node.isEdge && node.computedConcurrentInvocations === 0 && node.computedInstances > 1) {
|
|
908
|
+
const badgeText = `${node.computedInstances}x`;
|
|
909
|
+
g.append('text')
|
|
910
|
+
.attr('x', x + node.width - 6)
|
|
911
|
+
.attr('y', y + NODE_HEADER_HEIGHT / 2 + META_FONT_SIZE * 0.35)
|
|
912
|
+
.attr('text-anchor', 'end')
|
|
913
|
+
.attr('font-family', FONT_FAMILY)
|
|
914
|
+
.attr('font-size', META_FONT_SIZE)
|
|
915
|
+
.attr('fill', mutedColor)
|
|
916
|
+
.attr('data-instance-node', node.id)
|
|
917
|
+
.style('cursor', 'pointer')
|
|
918
|
+
.text(badgeText);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Role badge dots — only shown when Capabilities legend is expanded
|
|
922
|
+
const showDots = activeGroup != null && activeGroup.toLowerCase() === 'capabilities';
|
|
923
|
+
const roles = showDots && !node.isEdge ? inferRoles(node.properties) : [];
|
|
924
|
+
if (roles.length > 0) {
|
|
925
|
+
// Move dots up above the collapse bar for collapsed groups
|
|
926
|
+
const dotY = isCollapsedGroup
|
|
927
|
+
? y + node.height - COLLAPSE_BAR_HEIGHT - NODE_PAD_BOTTOM / 2
|
|
928
|
+
: y + node.height - NODE_PAD_BOTTOM / 2;
|
|
929
|
+
const totalDotsWidth = roles.length * (ROLE_DOT_RADIUS * 2 + 2) - 2;
|
|
930
|
+
const startX = node.x - totalDotsWidth / 2 + ROLE_DOT_RADIUS;
|
|
931
|
+
for (let i = 0; i < roles.length; i++) {
|
|
932
|
+
g.append('circle')
|
|
933
|
+
.attr('cx', startX + i * (ROLE_DOT_RADIUS * 2 + 2))
|
|
934
|
+
.attr('cy', dotY)
|
|
935
|
+
.attr('r', ROLE_DOT_RADIUS)
|
|
936
|
+
.attr('fill', roles[i].color);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Collapse bar at bottom of collapsed group nodes (accent stripe, clipped to card)
|
|
941
|
+
if (isCollapsedGroup) {
|
|
942
|
+
const clipId = `clip-${node.id.replace(/[[\]\s]/g, '')}`;
|
|
943
|
+
g.append('clipPath').attr('id', clipId)
|
|
944
|
+
.append('rect')
|
|
945
|
+
.attr('x', x).attr('y', y)
|
|
946
|
+
.attr('width', node.width).attr('height', node.height)
|
|
947
|
+
.attr('rx', NODE_BORDER_RADIUS);
|
|
948
|
+
g.append('rect')
|
|
949
|
+
.attr('x', x + COLLAPSE_BAR_INSET)
|
|
950
|
+
.attr('y', y + node.height - COLLAPSE_BAR_HEIGHT)
|
|
951
|
+
.attr('width', node.width - COLLAPSE_BAR_INSET * 2)
|
|
952
|
+
.attr('height', COLLAPSE_BAR_HEIGHT)
|
|
953
|
+
.attr('fill', stroke)
|
|
954
|
+
.attr('clip-path', `url(#${clipId})`)
|
|
955
|
+
.attr('class', 'infra-collapse-bar');
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ============================================================
|
|
962
|
+
// Reject Particles — red fading drops for traffic-shedding nodes
|
|
963
|
+
// ============================================================
|
|
964
|
+
|
|
965
|
+
/** Get a numeric property from an InfraLayoutNode. */
|
|
966
|
+
function getNodeNumProp(node: InfraLayoutNode, key: string, fallback: number): number {
|
|
967
|
+
const prop = node.properties.find((p) => p.key === key);
|
|
968
|
+
if (!prop) return fallback;
|
|
969
|
+
if (typeof prop.value === 'number') return prop.value;
|
|
970
|
+
const num = parseFloat(String(prop.value));
|
|
971
|
+
return isNaN(num) ? fallback : num;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Compute rejected RPS for a node — traffic that arrives but is dropped/blocked.
|
|
976
|
+
* This includes: firewall-block, rate-limit excess, and overload excess.
|
|
977
|
+
* Cache-hit is NOT a reject — the request is served from cache.
|
|
978
|
+
*/
|
|
979
|
+
function computeRejectedRps(node: InfraLayoutNode): number {
|
|
980
|
+
if (node.isEdge || node.computedRps <= 0) return 0;
|
|
981
|
+
let rejected = 0;
|
|
982
|
+
const inbound = node.computedRps;
|
|
983
|
+
|
|
984
|
+
// Firewall block: N% of inbound is rejected
|
|
985
|
+
const fwBlock = getNodeNumProp(node, 'firewall-block', 0);
|
|
986
|
+
if (fwBlock > 0) rejected += inbound * (fwBlock / 100);
|
|
987
|
+
|
|
988
|
+
// Rate limit excess (applied after cache/fw reductions)
|
|
989
|
+
const rateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
|
|
990
|
+
if (rateLimit > 0) {
|
|
991
|
+
let preRateLimitRps = inbound;
|
|
992
|
+
const cacheHit = getNodeNumProp(node, 'cache-hit', 0);
|
|
993
|
+
if (cacheHit > 0) preRateLimitRps *= (100 - cacheHit) / 100;
|
|
994
|
+
if (fwBlock > 0) preRateLimitRps *= (100 - fwBlock) / 100;
|
|
995
|
+
if (preRateLimitRps > rateLimit) {
|
|
996
|
+
rejected += preRateLimitRps - rateLimit;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Overload excess
|
|
1001
|
+
if (node.overloaded || node.childHealthState === 'overloaded') {
|
|
1002
|
+
const maxRps = getNodeNumProp(node, 'max-rps', 0);
|
|
1003
|
+
if (maxRps > 0) {
|
|
1004
|
+
const capacity = maxRps * node.computedInstances;
|
|
1005
|
+
if (inbound > capacity) {
|
|
1006
|
+
rejected += inbound - capacity;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
// For collapsed groups: childHealthState signals child overload even when
|
|
1010
|
+
// the virtual node's aggregated capacity math doesn't trigger (due to
|
|
1011
|
+
// group instances being baked into both max-rps and computedInstances).
|
|
1012
|
+
// Ensure we always produce a reject signal.
|
|
1013
|
+
if (rejected === 0) {
|
|
1014
|
+
rejected = inbound * 0.1;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return rejected;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function renderRejectParticles(
|
|
1022
|
+
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1023
|
+
nodes: InfraLayoutNode[],
|
|
1024
|
+
) {
|
|
1025
|
+
// Compute max rejected RPS across all nodes for scaling
|
|
1026
|
+
const rejectMap: { node: InfraLayoutNode; rejected: number }[] = [];
|
|
1027
|
+
for (const node of nodes) {
|
|
1028
|
+
const rejected = computeRejectedRps(node);
|
|
1029
|
+
if (rejected > 0) rejectMap.push({ node, rejected });
|
|
1030
|
+
}
|
|
1031
|
+
if (rejectMap.length === 0) return;
|
|
1032
|
+
|
|
1033
|
+
const maxRejected = Math.max(...rejectMap.map((r) => r.rejected));
|
|
1034
|
+
|
|
1035
|
+
for (const { node, rejected } of rejectMap) {
|
|
1036
|
+
const t = Math.min(rejected / maxRejected, 1);
|
|
1037
|
+
const count = Math.round(REJECT_COUNT_MIN + t * (REJECT_COUNT_MAX - REJECT_COUNT_MIN));
|
|
1038
|
+
const dur = REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
|
|
1039
|
+
|
|
1040
|
+
const nodeBottom = node.y + node.height / 2;
|
|
1041
|
+
|
|
1042
|
+
for (let i = 0; i < count; i++) {
|
|
1043
|
+
const delay = (dur / count) * i;
|
|
1044
|
+
// Spread particles horizontally around the node center
|
|
1045
|
+
const spread = node.width * 0.3;
|
|
1046
|
+
const startX = node.x + (count > 1 ? -spread / 2 + spread * (i / (count - 1)) : 0);
|
|
1047
|
+
const startY = nodeBottom;
|
|
1048
|
+
const endY = nodeBottom + REJECT_DROP_DISTANCE;
|
|
1049
|
+
|
|
1050
|
+
const rejectColor = node.overloaded || node.childHealthState === 'overloaded'
|
|
1051
|
+
? COLOR_OVERLOADED : COLOR_WARNING;
|
|
1052
|
+
const circle = svg.append('circle')
|
|
1053
|
+
.attr('r', REJECT_PARTICLE_R)
|
|
1054
|
+
.attr('fill', rejectColor)
|
|
1055
|
+
.attr('opacity', 0);
|
|
1056
|
+
|
|
1057
|
+
// Drop straight down from node bottom
|
|
1058
|
+
circle.append('animate')
|
|
1059
|
+
.attr('attributeName', 'cy')
|
|
1060
|
+
.attr('from', startY)
|
|
1061
|
+
.attr('to', endY)
|
|
1062
|
+
.attr('dur', `${dur}s`)
|
|
1063
|
+
.attr('repeatCount', 'indefinite')
|
|
1064
|
+
.attr('begin', `${delay}s`);
|
|
1065
|
+
|
|
1066
|
+
circle.append('animate')
|
|
1067
|
+
.attr('attributeName', 'cx')
|
|
1068
|
+
.attr('from', startX)
|
|
1069
|
+
.attr('to', startX)
|
|
1070
|
+
.attr('dur', `${dur}s`)
|
|
1071
|
+
.attr('repeatCount', 'indefinite')
|
|
1072
|
+
.attr('begin', `${delay}s`);
|
|
1073
|
+
|
|
1074
|
+
// Fade: appear quickly, then fade out
|
|
1075
|
+
circle.append('animate')
|
|
1076
|
+
.attr('attributeName', 'opacity')
|
|
1077
|
+
.attr('values', '0;0.8;0.6;0')
|
|
1078
|
+
.attr('keyTimes', '0;0.1;0.5;1')
|
|
1079
|
+
.attr('dur', `${dur}s`)
|
|
1080
|
+
.attr('repeatCount', 'indefinite')
|
|
1081
|
+
.attr('begin', `${delay}s`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ============================================================
|
|
1087
|
+
// Legend — pill/capsule groups (matching org chart style)
|
|
1088
|
+
// ============================================================
|
|
1089
|
+
|
|
1090
|
+
interface InfraLegendEntry {
|
|
1091
|
+
value: string;
|
|
1092
|
+
color: string;
|
|
1093
|
+
/** For role: kebab-case role name. For tag: lowercase tag value. */
|
|
1094
|
+
key: string;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
export interface InfraLegendGroup {
|
|
1098
|
+
name: string;
|
|
1099
|
+
type: 'role' | 'tag';
|
|
1100
|
+
/** For tag groups: the key used in data-tag-* attributes (alias or name). */
|
|
1101
|
+
tagKey?: string;
|
|
1102
|
+
entries: InfraLegendEntry[];
|
|
1103
|
+
width: number;
|
|
1104
|
+
minifiedWidth: number;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** Build legend groups from roles + tags. */
|
|
1108
|
+
export function computeInfraLegendGroups(
|
|
1109
|
+
nodes: InfraLayoutNode[],
|
|
1110
|
+
tagGroups: InfraTagGroup[],
|
|
1111
|
+
palette: PaletteColors,
|
|
1112
|
+
): InfraLegendGroup[] {
|
|
1113
|
+
const groups: InfraLegendGroup[] = [];
|
|
1114
|
+
|
|
1115
|
+
// Capabilities group (from inferred roles)
|
|
1116
|
+
const roles = collectDiagramRoles(nodes.filter((n) => !n.isEdge).map((n) => n.properties));
|
|
1117
|
+
if (roles.length > 0) {
|
|
1118
|
+
const entries = roles.map((r) => ({
|
|
1119
|
+
value: r.name,
|
|
1120
|
+
color: r.color,
|
|
1121
|
+
key: r.name.toLowerCase().replace(/\s+/g, '-'),
|
|
1122
|
+
}));
|
|
1123
|
+
const pillWidth = 'Capabilities'.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
1124
|
+
let entriesWidth = 0;
|
|
1125
|
+
for (const e of entries) {
|
|
1126
|
+
entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
|
|
1127
|
+
}
|
|
1128
|
+
groups.push({
|
|
1129
|
+
name: 'Capabilities',
|
|
1130
|
+
type: 'role',
|
|
1131
|
+
entries,
|
|
1132
|
+
width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
|
|
1133
|
+
minifiedWidth: pillWidth,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Tag groups
|
|
1138
|
+
for (const tg of tagGroups) {
|
|
1139
|
+
const entries: InfraLegendEntry[] = [];
|
|
1140
|
+
for (const tv of tg.values) {
|
|
1141
|
+
if (tv.color) {
|
|
1142
|
+
entries.push({
|
|
1143
|
+
value: tv.name,
|
|
1144
|
+
color: resolveColor(tv.color, palette),
|
|
1145
|
+
key: tv.name.toLowerCase(),
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (entries.length === 0) continue;
|
|
1150
|
+
const pillWidth = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
1151
|
+
let entriesWidth = 0;
|
|
1152
|
+
for (const e of entries) {
|
|
1153
|
+
entriesWidth += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
|
|
1154
|
+
}
|
|
1155
|
+
groups.push({
|
|
1156
|
+
name: tg.name,
|
|
1157
|
+
type: 'tag',
|
|
1158
|
+
tagKey: (tg.alias ?? tg.name).toLowerCase(),
|
|
1159
|
+
entries,
|
|
1160
|
+
width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
|
|
1161
|
+
minifiedWidth: pillWidth,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return groups;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Compute total width for the playback pill (speed only). */
|
|
1169
|
+
function computePlaybackWidth(playback: InfraPlaybackState | undefined): number {
|
|
1170
|
+
if (!playback) return 0;
|
|
1171
|
+
const pillWidth = 'Playback'.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
1172
|
+
if (!playback.expanded) return pillWidth;
|
|
1173
|
+
|
|
1174
|
+
let entriesW = 8; // gap after pill
|
|
1175
|
+
entriesW += LEGEND_PILL_FONT_SIZE * 0.8 + 6; // play/pause
|
|
1176
|
+
for (const s of playback.speedOptions) {
|
|
1177
|
+
entriesW += `${s}x`.length * LEGEND_ENTRY_FONT_W + 6;
|
|
1178
|
+
}
|
|
1179
|
+
return LEGEND_CAPSULE_PAD * 2 + pillWidth + entriesW;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** Whether a separate Scenario pill should render. */
|
|
1183
|
+
function renderLegend(
|
|
1184
|
+
rootSvg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
1185
|
+
legendGroups: InfraLegendGroup[],
|
|
1186
|
+
totalWidth: number,
|
|
1187
|
+
legendY: number,
|
|
1188
|
+
palette: PaletteColors,
|
|
1189
|
+
isDark: boolean,
|
|
1190
|
+
activeGroup: string | null,
|
|
1191
|
+
playback?: InfraPlaybackState,
|
|
1192
|
+
) {
|
|
1193
|
+
if (legendGroups.length === 0 && !playback) return;
|
|
1194
|
+
|
|
1195
|
+
const legendG = rootSvg.append('g')
|
|
1196
|
+
.attr('transform', `translate(0, ${legendY})`);
|
|
1197
|
+
|
|
1198
|
+
// Compute centered positions
|
|
1199
|
+
const effectiveW = (g: InfraLegendGroup) =>
|
|
1200
|
+
activeGroup != null && g.name.toLowerCase() === activeGroup.toLowerCase() ? g.width : g.minifiedWidth;
|
|
1201
|
+
const playbackW = computePlaybackWidth(playback);
|
|
1202
|
+
const trailingGaps = legendGroups.length > 0 && playbackW > 0 ? LEGEND_GROUP_GAP : 0;
|
|
1203
|
+
const totalLegendW = legendGroups.reduce((s, g) => s + effectiveW(g), 0)
|
|
1204
|
+
+ (legendGroups.length - 1) * LEGEND_GROUP_GAP
|
|
1205
|
+
+ trailingGaps + playbackW;
|
|
1206
|
+
let cursorX = (totalWidth - totalLegendW) / 2;
|
|
1207
|
+
|
|
1208
|
+
for (const group of legendGroups) {
|
|
1209
|
+
const isActive = activeGroup != null && group.name.toLowerCase() === activeGroup.toLowerCase();
|
|
1210
|
+
|
|
1211
|
+
const groupBg = isDark
|
|
1212
|
+
? mix(palette.bg, palette.text, 85)
|
|
1213
|
+
: mix(palette.bg, palette.text, 92);
|
|
1214
|
+
|
|
1215
|
+
const pillLabel = group.name;
|
|
1216
|
+
const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
1217
|
+
|
|
1218
|
+
const gEl = legendG
|
|
1219
|
+
.append('g')
|
|
1220
|
+
.attr('transform', `translate(${cursorX}, 0)`)
|
|
1221
|
+
.attr('class', 'infra-legend-group')
|
|
1222
|
+
.attr('data-legend-group', group.name.toLowerCase())
|
|
1223
|
+
.attr('data-legend-type', group.type)
|
|
1224
|
+
.style('cursor', 'pointer');
|
|
1225
|
+
|
|
1226
|
+
// Outer capsule background (active only)
|
|
1227
|
+
if (isActive) {
|
|
1228
|
+
gEl.append('rect')
|
|
1229
|
+
.attr('width', group.width)
|
|
1230
|
+
.attr('height', LEGEND_HEIGHT)
|
|
1231
|
+
.attr('rx', LEGEND_HEIGHT / 2)
|
|
1232
|
+
.attr('fill', groupBg);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
1236
|
+
const pillYOff = isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
1237
|
+
const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
1238
|
+
|
|
1239
|
+
// Pill background
|
|
1240
|
+
gEl.append('rect')
|
|
1241
|
+
.attr('x', pillXOff)
|
|
1242
|
+
.attr('y', pillYOff)
|
|
1243
|
+
.attr('width', pillWidth)
|
|
1244
|
+
.attr('height', pillH)
|
|
1245
|
+
.attr('rx', pillH / 2)
|
|
1246
|
+
.attr('fill', isActive ? palette.bg : groupBg);
|
|
1247
|
+
|
|
1248
|
+
// Active pill border
|
|
1249
|
+
if (isActive) {
|
|
1250
|
+
gEl.append('rect')
|
|
1251
|
+
.attr('x', pillXOff)
|
|
1252
|
+
.attr('y', pillYOff)
|
|
1253
|
+
.attr('width', pillWidth)
|
|
1254
|
+
.attr('height', pillH)
|
|
1255
|
+
.attr('rx', pillH / 2)
|
|
1256
|
+
.attr('fill', 'none')
|
|
1257
|
+
.attr('stroke', isDark ? mix(palette.textMuted, palette.bg, 50) : mix(palette.textMuted, palette.bg, 50))
|
|
1258
|
+
.attr('stroke-width', 0.75);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Pill text
|
|
1262
|
+
gEl.append('text')
|
|
1263
|
+
.attr('x', pillXOff + pillWidth / 2)
|
|
1264
|
+
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
1265
|
+
.attr('font-family', FONT_FAMILY)
|
|
1266
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
1267
|
+
.attr('font-weight', '500')
|
|
1268
|
+
.attr('fill', isActive ? palette.text : palette.textMuted)
|
|
1269
|
+
.attr('text-anchor', 'middle')
|
|
1270
|
+
.text(pillLabel);
|
|
1271
|
+
|
|
1272
|
+
// Entries inside capsule (active only)
|
|
1273
|
+
if (isActive) {
|
|
1274
|
+
let entryX = pillXOff + pillWidth + 4;
|
|
1275
|
+
for (const entry of group.entries) {
|
|
1276
|
+
const entryG = gEl
|
|
1277
|
+
.append('g')
|
|
1278
|
+
.attr('class', 'infra-legend-entry')
|
|
1279
|
+
.attr('data-legend-entry', entry.key)
|
|
1280
|
+
.attr('data-legend-type', group.type)
|
|
1281
|
+
.attr('data-legend-color', entry.color)
|
|
1282
|
+
.style('cursor', 'pointer');
|
|
1283
|
+
|
|
1284
|
+
if (group.type === 'tag' && group.tagKey) {
|
|
1285
|
+
entryG.attr('data-legend-tag-group', group.tagKey);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
entryG.append('circle')
|
|
1289
|
+
.attr('cx', entryX + LEGEND_DOT_R)
|
|
1290
|
+
.attr('cy', LEGEND_HEIGHT / 2)
|
|
1291
|
+
.attr('r', LEGEND_DOT_R)
|
|
1292
|
+
.attr('fill', entry.color);
|
|
1293
|
+
|
|
1294
|
+
const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
|
|
1295
|
+
entryG.append('text')
|
|
1296
|
+
.attr('x', textX)
|
|
1297
|
+
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
|
|
1298
|
+
.attr('font-family', FONT_FAMILY)
|
|
1299
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
1300
|
+
.attr('fill', palette.textMuted)
|
|
1301
|
+
.text(entry.value);
|
|
1302
|
+
|
|
1303
|
+
entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
cursorX += effectiveW(group) + LEGEND_GROUP_GAP;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Playback pill — speed + pause only
|
|
1311
|
+
if (playback) {
|
|
1312
|
+
const isExpanded = playback.expanded;
|
|
1313
|
+
const groupBg = isDark
|
|
1314
|
+
? mix(palette.bg, palette.text, 85)
|
|
1315
|
+
: mix(palette.bg, palette.text, 92);
|
|
1316
|
+
|
|
1317
|
+
const pillLabel = 'Playback';
|
|
1318
|
+
const pillWidth = pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
1319
|
+
const fullW = computePlaybackWidth(playback);
|
|
1320
|
+
|
|
1321
|
+
const pbG = legendG
|
|
1322
|
+
.append('g')
|
|
1323
|
+
.attr('transform', `translate(${cursorX}, 0)`)
|
|
1324
|
+
.attr('class', 'infra-legend-group infra-playback-pill')
|
|
1325
|
+
.style('cursor', 'pointer');
|
|
1326
|
+
|
|
1327
|
+
if (isExpanded) {
|
|
1328
|
+
pbG.append('rect')
|
|
1329
|
+
.attr('width', fullW)
|
|
1330
|
+
.attr('height', LEGEND_HEIGHT)
|
|
1331
|
+
.attr('rx', LEGEND_HEIGHT / 2)
|
|
1332
|
+
.attr('fill', groupBg);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const pillXOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
|
|
1336
|
+
const pillYOff = isExpanded ? LEGEND_CAPSULE_PAD : 0;
|
|
1337
|
+
const pillH = LEGEND_HEIGHT - (isExpanded ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
1338
|
+
|
|
1339
|
+
pbG.append('rect')
|
|
1340
|
+
.attr('x', pillXOff).attr('y', pillYOff)
|
|
1341
|
+
.attr('width', pillWidth).attr('height', pillH)
|
|
1342
|
+
.attr('rx', pillH / 2)
|
|
1343
|
+
.attr('fill', isExpanded ? palette.bg : groupBg);
|
|
1344
|
+
|
|
1345
|
+
if (isExpanded) {
|
|
1346
|
+
pbG.append('rect')
|
|
1347
|
+
.attr('x', pillXOff).attr('y', pillYOff)
|
|
1348
|
+
.attr('width', pillWidth).attr('height', pillH)
|
|
1349
|
+
.attr('rx', pillH / 2)
|
|
1350
|
+
.attr('fill', 'none')
|
|
1351
|
+
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
1352
|
+
.attr('stroke-width', 0.75);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
pbG.append('text')
|
|
1356
|
+
.attr('x', pillXOff + pillWidth / 2)
|
|
1357
|
+
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
1358
|
+
.attr('font-family', FONT_FAMILY)
|
|
1359
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
1360
|
+
.attr('font-weight', '500')
|
|
1361
|
+
.attr('fill', isExpanded ? palette.text : palette.textMuted)
|
|
1362
|
+
.attr('text-anchor', 'middle')
|
|
1363
|
+
.text(pillLabel);
|
|
1364
|
+
|
|
1365
|
+
if (isExpanded) {
|
|
1366
|
+
let entryX = pillXOff + pillWidth + 8;
|
|
1367
|
+
const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
|
|
1368
|
+
|
|
1369
|
+
const ppLabel = playback.paused ? '▶' : '⏸';
|
|
1370
|
+
pbG.append('text')
|
|
1371
|
+
.attr('x', entryX).attr('y', entryY)
|
|
1372
|
+
.attr('font-family', FONT_FAMILY)
|
|
1373
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
1374
|
+
.attr('fill', palette.textMuted)
|
|
1375
|
+
.attr('data-playback-action', 'toggle-pause')
|
|
1376
|
+
.style('cursor', 'pointer')
|
|
1377
|
+
.text(ppLabel);
|
|
1378
|
+
entryX += LEGEND_PILL_FONT_SIZE * 0.8 + 6;
|
|
1379
|
+
|
|
1380
|
+
for (const s of playback.speedOptions) {
|
|
1381
|
+
const label = `${s}x`;
|
|
1382
|
+
const isActive = playback.speed === s;
|
|
1383
|
+
pbG.append('text')
|
|
1384
|
+
.attr('x', entryX).attr('y', entryY)
|
|
1385
|
+
.attr('font-family', FONT_FAMILY)
|
|
1386
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
1387
|
+
.attr('font-weight', isActive ? '600' : '400')
|
|
1388
|
+
.attr('fill', isActive ? palette.primary : palette.textMuted)
|
|
1389
|
+
.attr('data-playback-action', 'set-speed')
|
|
1390
|
+
.attr('data-playback-value', String(s))
|
|
1391
|
+
.style('cursor', 'pointer')
|
|
1392
|
+
.text(label);
|
|
1393
|
+
entryX += label.length * LEGEND_ENTRY_FONT_W + 6;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
cursorX += fullW + LEGEND_GROUP_GAP;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ============================================================
|
|
1403
|
+
// Main render
|
|
1404
|
+
// ============================================================
|
|
1405
|
+
|
|
1406
|
+
export interface InfraPlaybackState {
|
|
1407
|
+
expanded: boolean;
|
|
1408
|
+
paused: boolean;
|
|
1409
|
+
speed: number;
|
|
1410
|
+
speedOptions: readonly number[];
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
export function renderInfra(
|
|
1414
|
+
container: HTMLDivElement,
|
|
1415
|
+
layout: InfraLayoutResult,
|
|
1416
|
+
palette: PaletteColors,
|
|
1417
|
+
isDark: boolean,
|
|
1418
|
+
title: string | null,
|
|
1419
|
+
titleLineNumber: number | null,
|
|
1420
|
+
tagGroups?: InfraTagGroup[],
|
|
1421
|
+
activeGroup?: string | null,
|
|
1422
|
+
animate?: boolean,
|
|
1423
|
+
playback?: InfraPlaybackState | null,
|
|
1424
|
+
selectedNodeId?: string | null,
|
|
1425
|
+
exportMode?: boolean,
|
|
1426
|
+
collapsedNodes?: Set<string> | null,
|
|
1427
|
+
) {
|
|
1428
|
+
// Clear previous render (preserve tooltips if any)
|
|
1429
|
+
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
1430
|
+
|
|
1431
|
+
// Build legend groups
|
|
1432
|
+
const legendGroups = computeInfraLegendGroups(layout.nodes, tagGroups ?? [], palette);
|
|
1433
|
+
const hasLegend = legendGroups.length > 0 || !!playback;
|
|
1434
|
+
// In app mode (not export), legend is rendered as a separate fixed-size SVG
|
|
1435
|
+
const fixedLegend = !exportMode && hasLegend;
|
|
1436
|
+
const legendOffset = hasLegend && !fixedLegend ? LEGEND_HEIGHT : 0;
|
|
1437
|
+
|
|
1438
|
+
const titleOffset = title ? 40 : 0;
|
|
1439
|
+
const totalWidth = layout.width;
|
|
1440
|
+
const totalHeight = layout.height + titleOffset + legendOffset;
|
|
1441
|
+
|
|
1442
|
+
const shouldAnimate = animate !== false;
|
|
1443
|
+
|
|
1444
|
+
const rootSvg = d3Selection.select(container)
|
|
1445
|
+
.append('svg')
|
|
1446
|
+
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
1447
|
+
.attr('width', '100%')
|
|
1448
|
+
.attr('height', fixedLegend ? `calc(100% - ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}px)` : '100%')
|
|
1449
|
+
.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`)
|
|
1450
|
+
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
1451
|
+
|
|
1452
|
+
// Inject animation keyframes + edge label hover styles
|
|
1453
|
+
if (shouldAnimate) {
|
|
1454
|
+
rootSvg.append('style').text(`
|
|
1455
|
+
@keyframes infra-pulse-warning {
|
|
1456
|
+
0%, 100% { opacity: 1; }
|
|
1457
|
+
50% { opacity: 0.7; }
|
|
1458
|
+
}
|
|
1459
|
+
@keyframes infra-pulse-overload {
|
|
1460
|
+
0%, 100% { stroke-width: ${OVERLOAD_STROKE_WIDTH}; }
|
|
1461
|
+
50% { stroke-width: ${OVERLOAD_STROKE_WIDTH + 2}; }
|
|
1462
|
+
}
|
|
1463
|
+
@keyframes infra-pulse-cb {
|
|
1464
|
+
0%, 49% { stroke-dasharray: none; }
|
|
1465
|
+
50%, 100% { stroke-dasharray: 6 4; }
|
|
1466
|
+
}
|
|
1467
|
+
.infra-node-warning > rect:first-of-type {
|
|
1468
|
+
animation: infra-pulse-warning ${NODE_PULSE_SPEED}s ease-in-out infinite;
|
|
1469
|
+
}
|
|
1470
|
+
.infra-node-overload > rect:first-of-type {
|
|
1471
|
+
animation: infra-pulse-overload ${NODE_PULSE_OVERLOAD}s ease-in-out infinite;
|
|
1472
|
+
}
|
|
1473
|
+
.infra-node-cb-open > rect:first-of-type {
|
|
1474
|
+
stroke-dasharray: 6 4;
|
|
1475
|
+
animation: infra-pulse-cb 1s step-end infinite;
|
|
1476
|
+
}
|
|
1477
|
+
@keyframes infra-edge-throb {
|
|
1478
|
+
0%, 100% { stroke-width: 1.5; }
|
|
1479
|
+
50% { stroke-width: 3; }
|
|
1480
|
+
}
|
|
1481
|
+
.infra-node-edge-throb > rect:first-of-type {
|
|
1482
|
+
animation: infra-edge-throb 2s ease-in-out infinite;
|
|
1483
|
+
cursor: pointer;
|
|
1484
|
+
}
|
|
1485
|
+
.infra-edge-label {
|
|
1486
|
+
opacity: 0;
|
|
1487
|
+
transition: opacity 0.15s ease;
|
|
1488
|
+
pointer-events: all;
|
|
1489
|
+
}
|
|
1490
|
+
.infra-edge-label:hover {
|
|
1491
|
+
opacity: 1;
|
|
1492
|
+
}
|
|
1493
|
+
`);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const svg = rootSvg.append('g')
|
|
1497
|
+
.attr('transform', `translate(0, ${titleOffset})`);
|
|
1498
|
+
|
|
1499
|
+
// Title
|
|
1500
|
+
if (title) {
|
|
1501
|
+
rootSvg.append('text')
|
|
1502
|
+
.attr('class', 'chart-title')
|
|
1503
|
+
.attr('x', totalWidth / 2)
|
|
1504
|
+
.attr('y', 28)
|
|
1505
|
+
.attr('text-anchor', 'middle')
|
|
1506
|
+
.attr('font-family', FONT_FAMILY)
|
|
1507
|
+
.attr('font-size', 18)
|
|
1508
|
+
.attr('font-weight', '700')
|
|
1509
|
+
.attr('fill', palette.text)
|
|
1510
|
+
.attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
|
|
1511
|
+
.text(title);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
|
|
1515
|
+
renderGroups(svg, layout.groups, palette, isDark);
|
|
1516
|
+
renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
|
|
1517
|
+
renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, selectedNodeId, activeGroup, layout.options, collapsedNodes, tagGroups ?? []);
|
|
1518
|
+
if (shouldAnimate) {
|
|
1519
|
+
renderRejectParticles(svg, layout.nodes);
|
|
1520
|
+
}
|
|
1521
|
+
renderEdgeLabels(svg, layout.edges, palette, isDark, shouldAnimate);
|
|
1522
|
+
|
|
1523
|
+
// Legend at bottom
|
|
1524
|
+
if (hasLegend) {
|
|
1525
|
+
if (fixedLegend) {
|
|
1526
|
+
// Render legend in a separate SVG that stays at fixed pixel size
|
|
1527
|
+
const containerWidth = container.clientWidth || totalWidth;
|
|
1528
|
+
const legendSvg = d3Selection.select(container)
|
|
1529
|
+
.append('svg')
|
|
1530
|
+
.attr('class', 'infra-legend-fixed')
|
|
1531
|
+
.attr('width', '100%')
|
|
1532
|
+
.attr('height', LEGEND_HEIGHT + LEGEND_FIXED_GAP)
|
|
1533
|
+
.attr('viewBox', `0 0 ${containerWidth} ${LEGEND_HEIGHT + LEGEND_FIXED_GAP}`)
|
|
1534
|
+
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
1535
|
+
.style('display', 'block');
|
|
1536
|
+
renderLegend(legendSvg, legendGroups, containerWidth, LEGEND_FIXED_GAP / 2, palette, isDark, activeGroup ?? null, playback ?? undefined);
|
|
1537
|
+
} else {
|
|
1538
|
+
renderLegend(rootSvg, legendGroups, totalWidth, titleOffset + layout.height + 4, palette, isDark, activeGroup ?? null, playback ?? undefined);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// ============================================================
|
|
1544
|
+
// Export pipeline (for CLI / d3.ts integration)
|
|
1545
|
+
// ============================================================
|
|
1546
|
+
|
|
1547
|
+
export function parseAndLayoutInfra(content: string) {
|
|
1548
|
+
const parsed = parseInfra(content);
|
|
1549
|
+
if (parsed.error) return { parsed, computed: null, layout: null };
|
|
1550
|
+
const computed = computeInfra(parsed);
|
|
1551
|
+
const layout = layoutInfra(computed);
|
|
1552
|
+
return { parsed, computed, layout };
|
|
1553
|
+
}
|