@diagrammo/dgmo 0.47.0 → 0.48.0
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/dist/auto.js +1 -1
- package/dist/auto.mjs +1 -1
- package/dist/element.js +1 -1
- package/dist/element.mjs +1 -1
- package/package.json +19 -70
- package/dist/advanced.cjs +0 -71648
- package/dist/advanced.d.cts +0 -5719
- package/dist/auto.cjs +0 -70648
- package/dist/auto.d.cts +0 -54
- package/dist/block.cjs +0 -70208
- package/dist/block.d.cts +0 -115
- package/dist/editor.cjs +0 -526
- package/dist/editor.d.cts +0 -26
- package/dist/element.cjs +0 -70530
- package/dist/element.d.cts +0 -62
- package/dist/highlight.cjs +0 -852
- package/dist/highlight.d.cts +0 -49
- package/dist/index.cjs +0 -70491
- package/dist/index.d.cts +0 -160
- package/dist/pert.cjs +0 -325
- package/dist/pert.d.cts +0 -559
- package/dist/themes-1-CuKpeH.d.cts +0 -908
- package/src/advanced.ts +0 -873
- package/src/arc/parser.ts +0 -15
- package/src/arc/renderer.ts +0 -633
- package/src/auto/index.ts +0 -621
- package/src/auto/shared.ts +0 -370
- package/src/auto/styles.ts +0 -73
- package/src/block/layout.ts +0 -198
- package/src/block/parser.ts +0 -427
- package/src/block/renderer.ts +0 -484
- package/src/block/types.ts +0 -67
- package/src/boxes-and-lines/collapse.ts +0 -96
- package/src/boxes-and-lines/focus.ts +0 -257
- package/src/boxes-and-lines/label-placement.ts +0 -339
- package/src/boxes-and-lines/layout-grouped.ts +0 -912
- package/src/boxes-and-lines/layout-layered.ts +0 -722
- package/src/boxes-and-lines/layout-search.ts +0 -1678
- package/src/boxes-and-lines/layout.ts +0 -398
- package/src/boxes-and-lines/parser.ts +0 -1181
- package/src/boxes-and-lines/renderer.ts +0 -1465
- package/src/boxes-and-lines/types.ts +0 -66
- package/src/c4/layout.ts +0 -2441
- package/src/c4/parser.ts +0 -1158
- package/src/c4/renderer.ts +0 -2008
- package/src/c4/types.ts +0 -89
- package/src/chart-type-registry.ts +0 -529
- package/src/chart-types.ts +0 -225
- package/src/chart.ts +0 -685
- package/src/charts-d3/bar.ts +0 -338
- package/src/charts-d3/chord.ts +0 -140
- package/src/charts-d3/function.ts +0 -147
- package/src/charts-d3/funnel.ts +0 -104
- package/src/charts-d3/heatmap.ts +0 -145
- package/src/charts-d3/index.ts +0 -337
- package/src/charts-d3/interactions.ts +0 -477
- package/src/charts-d3/line.ts +0 -344
- package/src/charts-d3/mount.ts +0 -151
- package/src/charts-d3/pie.ts +0 -117
- package/src/charts-d3/polar.ts +0 -89
- package/src/charts-d3/radar.ts +0 -0
- package/src/charts-d3/sankey.ts +0 -0
- package/src/charts-d3/scatter.ts +0 -185
- package/src/charts-d3/shared.ts +0 -208
- package/src/class/layout.ts +0 -326
- package/src/class/parser.ts +0 -635
- package/src/class/renderer.ts +0 -793
- package/src/class/types.ts +0 -58
- package/src/cli-banner.ts +0 -106
- package/src/cli.ts +0 -1080
- package/src/colors-diagnostics.ts +0 -35
- package/src/colors.ts +0 -379
- package/src/completion-types.ts +0 -27
- package/src/completion.ts +0 -2668
- package/src/cycle/layout.ts +0 -1026
- package/src/cycle/parser.ts +0 -387
- package/src/cycle/renderer.ts +0 -653
- package/src/cycle/types.ts +0 -101
- package/src/d3.ts +0 -1434
- package/src/data-chart-parser.ts +0 -1007
- package/src/dgmo-router.ts +0 -296
- package/src/diagnostics-registry.ts +0 -108
- package/src/diagnostics.ts +0 -0
- package/src/dimensions.ts +0 -20
- package/src/directives-registry.ts +0 -396
- package/src/editor/dgmo.grammar +0 -76
- package/src/editor/dgmo.grammar.d.ts +0 -2
- package/src/editor/dgmo.grammar.js +0 -18
- package/src/editor/dgmo.grammar.terms.d.ts +0 -5
- package/src/editor/dgmo.grammar.terms.js +0 -35
- package/src/editor/highlight-api.ts +0 -596
- package/src/editor/highlight.ts +0 -36
- package/src/editor/index.ts +0 -27
- package/src/editor/keywords.ts +0 -128
- package/src/editor/tokens.ts +0 -30
- package/src/element/index.ts +0 -432
- package/src/embed/css.ts +0 -312
- package/src/embed/escape.ts +0 -15
- package/src/embed/index.ts +0 -280
- package/src/er/classify.ts +0 -228
- package/src/er/layout.ts +0 -530
- package/src/er/parser.ts +0 -750
- package/src/er/renderer.ts +0 -725
- package/src/er/types.ts +0 -52
- package/src/error-card.ts +0 -269
- package/src/event-line/diagnostics.ts +0 -117
- package/src/event-line/parser.ts +0 -643
- package/src/event-line/renderer.ts +0 -1742
- package/src/event-line/types.ts +0 -74
- package/src/fonts.ts +0 -3
- package/src/gantt/calculator.ts +0 -979
- package/src/gantt/parser.ts +0 -1641
- package/src/gantt/renderer.ts +0 -3787
- package/src/gantt/resolver.ts +0 -172
- package/src/gantt/types.ts +0 -206
- package/src/graph/diagnostics.ts +0 -64
- package/src/graph/edge-spline.ts +0 -29
- package/src/graph/flowchart-parser.ts +0 -759
- package/src/graph/flowchart-renderer.ts +0 -817
- package/src/graph/layout.ts +0 -472
- package/src/graph/notes.ts +0 -21
- package/src/graph/state-collapse.ts +0 -78
- package/src/graph/state-parser.ts +0 -574
- package/src/graph/state-renderer.ts +0 -653
- package/src/graph/types.ts +0 -65
- package/src/index.ts +0 -261
- package/src/infra/compute.ts +0 -1385
- package/src/infra/layout.ts +0 -888
- package/src/infra/parser.ts +0 -1234
- package/src/infra/renderer.ts +0 -2534
- package/src/infra/roles.ts +0 -75
- package/src/infra/types.ts +0 -216
- package/src/infra/validation.ts +0 -201
- package/src/journey-map/layout.ts +0 -490
- package/src/journey-map/parser.ts +0 -557
- package/src/journey-map/renderer.ts +0 -1580
- package/src/journey-map/types.ts +0 -47
- package/src/kanban/mutations.ts +0 -175
- package/src/kanban/parser.ts +0 -509
- package/src/kanban/renderer.ts +0 -1449
- package/src/kanban/types.ts +0 -39
- package/src/label-layout.ts +0 -368
- package/src/map/colorize.ts +0 -54
- package/src/map/completion.ts +0 -316
- package/src/map/context-labels.ts +0 -609
- package/src/map/data/PROVENANCE.json +0 -1
- package/src/map/data/README.md +0 -40
- package/src/map/data/airport-collisions.json +0 -1
- package/src/map/data/airports.json +0 -1
- package/src/map/data/gazetteer.json +0 -1
- package/src/map/data/lakes.json +0 -1
- package/src/map/data/mountain-ranges.json +0 -1
- package/src/map/data/na-lakes.json +0 -1
- package/src/map/data/na-land.json +0 -1
- package/src/map/data/region-names.json +0 -1
- package/src/map/data/rivers.json +0 -1
- package/src/map/data/types.ts +0 -121
- package/src/map/data/us-states.json +0 -1
- package/src/map/data/water-bodies.json +0 -1
- package/src/map/data/world-coarse.json +0 -1
- package/src/map/data/world-detail.json +0 -1
- package/src/map/diagnostics.ts +0 -134
- package/src/map/dimensions.ts +0 -150
- package/src/map/geo-query.ts +0 -295
- package/src/map/geo.ts +0 -438
- package/src/map/invert.ts +0 -111
- package/src/map/layout.ts +0 -5069
- package/src/map/legend-band.ts +0 -127
- package/src/map/load-data.ts +0 -186
- package/src/map/parser.ts +0 -783
- package/src/map/renderer.ts +0 -1445
- package/src/map/resolved-types.ts +0 -160
- package/src/map/resolver.ts +0 -1146
- package/src/map/types.ts +0 -194
- package/src/mindmap/collapse.ts +0 -67
- package/src/mindmap/layout.ts +0 -649
- package/src/mindmap/parser.ts +0 -430
- package/src/mindmap/renderer.ts +0 -637
- package/src/mindmap/text-wrap.ts +0 -218
- package/src/mindmap/types.ts +0 -56
- package/src/org/collapse.ts +0 -151
- package/src/org/layout.ts +0 -1262
- package/src/org/parser.ts +0 -519
- package/src/org/renderer.ts +0 -866
- package/src/org/resolver.ts +0 -636
- package/src/palettes/atlas.ts +0 -77
- package/src/palettes/blueprint.ts +0 -73
- package/src/palettes/catppuccin.ts +0 -84
- package/src/palettes/color-utils.ts +0 -443
- package/src/palettes/index.ts +0 -67
- package/src/palettes/nord.ts +0 -67
- package/src/palettes/registry.ts +0 -114
- package/src/palettes/slate.ts +0 -73
- package/src/palettes/tidewater.ts +0 -73
- package/src/palettes/tokyo-night.ts +0 -86
- package/src/palettes/types.ts +0 -88
- package/src/pert/analyzer.ts +0 -1183
- package/src/pert/diagnostics.ts +0 -110
- package/src/pert/index.ts +0 -43
- package/src/pert/internal.ts +0 -252
- package/src/pert/layout.ts +0 -971
- package/src/pert/monte-carlo.ts +0 -446
- package/src/pert/parser.ts +0 -1606
- package/src/pert/renderer.ts +0 -4009
- package/src/pert/share-normalize.ts +0 -62
- package/src/pert/types.ts +0 -604
- package/src/pyramid/parser.ts +0 -185
- package/src/pyramid/renderer.ts +0 -768
- package/src/pyramid/types.ts +0 -28
- package/src/quadrant/parser.ts +0 -18
- package/src/quadrant/renderer.ts +0 -638
- package/src/raci/diagnostics.ts +0 -213
- package/src/raci/index.ts +0 -23
- package/src/raci/mutations.ts +0 -321
- package/src/raci/parser.ts +0 -699
- package/src/raci/renderer.ts +0 -1627
- package/src/raci/types.ts +0 -82
- package/src/raci/variants.ts +0 -320
- package/src/render.ts +0 -268
- package/src/ring/parser.ts +0 -212
- package/src/ring/renderer.ts +0 -490
- package/src/ring/types.ts +0 -27
- package/src/sequence/collapse.ts +0 -174
- package/src/sequence/parser.ts +0 -1596
- package/src/sequence/participant-inference.ts +0 -152
- package/src/sequence/renderer.ts +0 -3168
- package/src/sequence/tag-resolution.ts +0 -164
- package/src/sharing.ts +0 -211
- package/src/sitemap/collapse.ts +0 -100
- package/src/sitemap/layout.ts +0 -925
- package/src/sitemap/parser.ts +0 -755
- package/src/sitemap/renderer.ts +0 -828
- package/src/sitemap/types.ts +0 -42
- package/src/slope/parser.ts +0 -18
- package/src/slope/renderer.ts +0 -378
- package/src/swimlane/diagnostics.ts +0 -94
- package/src/swimlane/layout.ts +0 -597
- package/src/swimlane/parser.ts +0 -613
- package/src/swimlane/renderer.ts +0 -681
- package/src/swimlane/types.ts +0 -151
- package/src/tech-radar/interactive.ts +0 -1129
- package/src/tech-radar/layout.ts +0 -197
- package/src/tech-radar/parser.ts +0 -436
- package/src/tech-radar/renderer.ts +0 -1239
- package/src/tech-radar/shared.ts +0 -151
- package/src/tech-radar/types.ts +0 -86
- package/src/themes.ts +0 -22
- package/src/timeline/parser.ts +0 -472
- package/src/timeline/renderer.ts +0 -3024
- package/src/timeline/types.ts +0 -33
- package/src/timeline/viz-parser.ts +0 -21
- package/src/treemap/diagnostics.ts +0 -62
- package/src/treemap/layout-radial.ts +0 -233
- package/src/treemap/layout.ts +0 -189
- package/src/treemap/parser.ts +0 -463
- package/src/treemap/renderer-radial.ts +0 -438
- package/src/treemap/renderer.ts +0 -572
- package/src/treemap/treemap-shared.ts +0 -309
- package/src/treemap/types.ts +0 -60
- package/src/utils/arrow-markers.ts +0 -51
- package/src/utils/arrows-diagnostics.ts +0 -62
- package/src/utils/arrows.ts +0 -227
- package/src/utils/brand.ts +0 -61
- package/src/utils/card.ts +0 -183
- package/src/utils/collapse-engine/tree-edges.ts +0 -115
- package/src/utils/collapse-engine/tree.ts +0 -117
- package/src/utils/d3-helpers.ts +0 -219
- package/src/utils/d3-types.ts +0 -9
- package/src/utils/description-helpers.ts +0 -38
- package/src/utils/duration.ts +0 -312
- package/src/utils/export-container.ts +0 -41
- package/src/utils/expr-eval.ts +0 -27
- package/src/utils/fit-canvas.ts +0 -64
- package/src/utils/hover-styles.ts +0 -673
- package/src/utils/inline-markdown.ts +0 -96
- package/src/utils/legend-constants.ts +0 -48
- package/src/utils/legend-d3.ts +0 -618
- package/src/utils/legend-integration.ts +0 -107
- package/src/utils/legend-layout.ts +0 -776
- package/src/utils/legend-svg.ts +0 -214
- package/src/utils/legend-types.ts +0 -272
- package/src/utils/name-normalize.ts +0 -163
- package/src/utils/note-box/constants.ts +0 -25
- package/src/utils/note-box/index.ts +0 -11
- package/src/utils/note-box/metrics.ts +0 -90
- package/src/utils/note-box/svg.ts +0 -331
- package/src/utils/notes/bounds.ts +0 -30
- package/src/utils/notes/build.ts +0 -131
- package/src/utils/notes/index.ts +0 -18
- package/src/utils/notes/model.ts +0 -19
- package/src/utils/notes/parse.ts +0 -131
- package/src/utils/notes/place.ts +0 -177
- package/src/utils/notes/resolve.ts +0 -88
- package/src/utils/number-format.ts +0 -36
- package/src/utils/parsing.ts +0 -953
- package/src/utils/reserved-key-registry.ts +0 -239
- package/src/utils/safe-href.ts +0 -38
- package/src/utils/scaling.ts +0 -97
- package/src/utils/svg-embed.ts +0 -251
- package/src/utils/tag-groups.ts +0 -607
- package/src/utils/text-measure.ts +0 -122
- package/src/utils/time-ticks.ts +0 -253
- package/src/utils/title-constants.ts +0 -24
- package/src/utils/visual-conventions.ts +0 -61
- package/src/utils/wrapped-desc.ts +0 -87
- package/src/venn/parser.ts +0 -18
- package/src/venn/renderer.ts +0 -1047
- package/src/version-control/diagnostics.ts +0 -67
- package/src/version-control/parser.ts +0 -544
- package/src/version-control/renderer.ts +0 -724
- package/src/version-control/types.ts +0 -100
- package/src/visualizations/parse.ts +0 -1560
- package/src/visualizations/types.ts +0 -262
- package/src/wireframe/layout.ts +0 -479
- package/src/wireframe/parser.ts +0 -1114
- package/src/wireframe/renderer.ts +0 -1318
- package/src/wireframe/types.ts +0 -110
- package/src/wordcloud/parser.ts +0 -18
- package/src/wordcloud/renderer.ts +0 -357
package/src/infra/renderer.ts
DELETED
|
@@ -1,2534 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Infra Chart SVG Renderer
|
|
3
|
-
// ============================================================
|
|
4
|
-
|
|
5
|
-
import { tagAttrKey } from '../utils/tag-groups';
|
|
6
|
-
import * as d3Selection from 'd3-selection';
|
|
7
|
-
import * as d3Shape from 'd3-shape';
|
|
8
|
-
import { FONT_FAMILY } from '../fonts';
|
|
9
|
-
import type { PaletteColors } from '../palettes';
|
|
10
|
-
import { contrastText, mix, shapeFill } from '../palettes/color-utils';
|
|
11
|
-
import type { InfraTagGroup } from './types';
|
|
12
|
-
import { resolveColor } from '../colors';
|
|
13
|
-
import { renderInlineText } from '../utils/inline-markdown';
|
|
14
|
-
import { preprocessDescriptionLine } from '../utils/description-helpers';
|
|
15
|
-
import {
|
|
16
|
-
measureText,
|
|
17
|
-
truncateText,
|
|
18
|
-
CHAR_WIDTH_RATIO,
|
|
19
|
-
} from '../utils/text-measure';
|
|
20
|
-
import type {
|
|
21
|
-
InfraLayoutResult,
|
|
22
|
-
InfraLayoutNode,
|
|
23
|
-
InfraLayoutEdge,
|
|
24
|
-
InfraLayoutGroup,
|
|
25
|
-
} from './layout';
|
|
26
|
-
import {
|
|
27
|
-
inferRoles,
|
|
28
|
-
collectDiagramRoles,
|
|
29
|
-
collectFanoutSourceIds,
|
|
30
|
-
FANOUT_ROLE,
|
|
31
|
-
} from './roles';
|
|
32
|
-
import { parseInfra } from './parser';
|
|
33
|
-
import { computeInfra } from './compute';
|
|
34
|
-
import { layoutInfra } from './layout';
|
|
35
|
-
import {
|
|
36
|
-
LEGEND_HEIGHT,
|
|
37
|
-
LEGEND_PILL_PAD,
|
|
38
|
-
LEGEND_PILL_FONT_SIZE,
|
|
39
|
-
LEGEND_CAPSULE_PAD,
|
|
40
|
-
LEGEND_DOT_R,
|
|
41
|
-
LEGEND_ENTRY_FONT_SIZE,
|
|
42
|
-
LEGEND_ENTRY_DOT_GAP,
|
|
43
|
-
LEGEND_ENTRY_TRAIL,
|
|
44
|
-
measureLegendText,
|
|
45
|
-
} from '../utils/legend-constants';
|
|
46
|
-
import { renderIntegratedLegend } from '../utils/legend-integration';
|
|
47
|
-
import { getMaxLegendReservedHeight } from '../utils/legend-layout';
|
|
48
|
-
import {
|
|
49
|
-
TITLE_FONT_SIZE,
|
|
50
|
-
TITLE_FONT_WEIGHT,
|
|
51
|
-
TITLE_Y,
|
|
52
|
-
TITLE_OFFSET,
|
|
53
|
-
} from '../utils/title-constants';
|
|
54
|
-
import { ScaleContext } from '../utils/scaling';
|
|
55
|
-
|
|
56
|
-
// ============================================================
|
|
57
|
-
// Constants
|
|
58
|
-
// ============================================================
|
|
59
|
-
|
|
60
|
-
const NODE_FONT_SIZE = 13;
|
|
61
|
-
// Intentional deviation (conventions §1): infra uses denser meta rows
|
|
62
|
-
// (10px font / 14px line height) than the 11/16 default.
|
|
63
|
-
const META_FONT_SIZE = 10;
|
|
64
|
-
const META_LINE_HEIGHT = 14;
|
|
65
|
-
const EDGE_LABEL_FONT_SIZE = 11;
|
|
66
|
-
const GROUP_LABEL_FONT_SIZE = 14;
|
|
67
|
-
const NODE_BORDER_RADIUS = 8;
|
|
68
|
-
import {
|
|
69
|
-
EDGE_STROKE_WIDTH,
|
|
70
|
-
NODE_STROKE_WIDTH,
|
|
71
|
-
COLLAPSE_BAR_HEIGHT,
|
|
72
|
-
COLLAPSE_BAR_INSET,
|
|
73
|
-
} from '../utils/visual-conventions'; // shared (Story 111.1)
|
|
74
|
-
const OVERLOAD_STROKE_WIDTH = 3;
|
|
75
|
-
const ROLE_DOT_RADIUS = 3;
|
|
76
|
-
const NODE_HEADER_HEIGHT = 28;
|
|
77
|
-
const NODE_SEPARATOR_GAP = 4;
|
|
78
|
-
const NODE_PAD_BOTTOM = 10;
|
|
79
|
-
|
|
80
|
-
const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
|
|
81
|
-
const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
|
|
82
|
-
const SPEED_BADGE_V_PAD = 3; // vertical padding inside active speed badge
|
|
83
|
-
const SPEED_BADGE_GAP = 6; // gap between speed option slots
|
|
84
|
-
|
|
85
|
-
interface ScaledInfraConstants {
|
|
86
|
-
sNodeFontSize: number;
|
|
87
|
-
sMetaFontSize: number;
|
|
88
|
-
sMetaLineHeight: number;
|
|
89
|
-
sEdgeLabelFontSize: number;
|
|
90
|
-
sGroupLabelFontSize: number;
|
|
91
|
-
sNodeBorderRadius: number;
|
|
92
|
-
sEdgeStrokeWidth: number;
|
|
93
|
-
sNodeStrokeWidth: number;
|
|
94
|
-
sOverloadStrokeWidth: number;
|
|
95
|
-
sRoleDotRadius: number;
|
|
96
|
-
sNodeSeparatorGap: number;
|
|
97
|
-
sNodePadBottom: number;
|
|
98
|
-
sCollapseBarHeight: number;
|
|
99
|
-
sCollapseBarInset: number;
|
|
100
|
-
sParticleR: number;
|
|
101
|
-
sRejectParticleR: number;
|
|
102
|
-
sRejectDropDistance: number;
|
|
103
|
-
sTitleFontSize: number;
|
|
104
|
-
sTitleY: number;
|
|
105
|
-
sLegendFixedGap: number;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function buildScaledConstants(ctx: ScaleContext): ScaledInfraConstants {
|
|
109
|
-
return {
|
|
110
|
-
sNodeFontSize: ctx.text(NODE_FONT_SIZE),
|
|
111
|
-
sMetaFontSize: ctx.text(META_FONT_SIZE),
|
|
112
|
-
sMetaLineHeight: ctx.structural(META_LINE_HEIGHT),
|
|
113
|
-
sEdgeLabelFontSize: ctx.text(EDGE_LABEL_FONT_SIZE),
|
|
114
|
-
sGroupLabelFontSize: ctx.text(GROUP_LABEL_FONT_SIZE),
|
|
115
|
-
sNodeBorderRadius: ctx.structural(NODE_BORDER_RADIUS),
|
|
116
|
-
sEdgeStrokeWidth: ctx.structural(EDGE_STROKE_WIDTH),
|
|
117
|
-
sNodeStrokeWidth: ctx.structural(NODE_STROKE_WIDTH),
|
|
118
|
-
sOverloadStrokeWidth: ctx.structural(OVERLOAD_STROKE_WIDTH),
|
|
119
|
-
sRoleDotRadius: ctx.structural(ROLE_DOT_RADIUS),
|
|
120
|
-
sNodeSeparatorGap: ctx.structural(NODE_SEPARATOR_GAP),
|
|
121
|
-
sNodePadBottom: ctx.structural(NODE_PAD_BOTTOM),
|
|
122
|
-
sCollapseBarHeight: ctx.structural(COLLAPSE_BAR_HEIGHT),
|
|
123
|
-
sCollapseBarInset: ctx.structural(COLLAPSE_BAR_INSET),
|
|
124
|
-
sParticleR: ctx.structural(PARTICLE_R),
|
|
125
|
-
sRejectParticleR: ctx.structural(PARTICLE_R),
|
|
126
|
-
sRejectDropDistance: ctx.structural(REJECT_DROP_DISTANCE),
|
|
127
|
-
sTitleFontSize: ctx.text(TITLE_FONT_SIZE),
|
|
128
|
-
sTitleY: ctx.structural(TITLE_Y),
|
|
129
|
-
sLegendFixedGap: ctx.aesthetic(LEGEND_FIXED_GAP),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Health colors (from UX spec)
|
|
134
|
-
const COLOR_HEALTHY = '#22c55e';
|
|
135
|
-
const COLOR_WARNING = '#eab308';
|
|
136
|
-
const COLOR_OVERLOADED = '#ef4444';
|
|
137
|
-
/** SLO thresholds resolved for a single node (chart-level + per-node override). */
|
|
138
|
-
interface NodeSlo {
|
|
139
|
-
availThreshold: number | null; // fraction e.g. 0.999
|
|
140
|
-
latencyP90: number | null; // ms e.g. 200
|
|
141
|
-
warningMargin: number; // fraction e.g. 0.05
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Resolve effective SLO for a node: per-node properties take precedence over chart-level options.
|
|
145
|
-
* Returns null if neither availThreshold nor latencyP90 is declared. */
|
|
146
|
-
function resolveNodeSlo(
|
|
147
|
-
node: InfraLayoutNode,
|
|
148
|
-
diagramOptions: Record<string, string>
|
|
149
|
-
): NodeSlo | null {
|
|
150
|
-
const nodeProp = (key: string) => node.properties.find((p) => p.key === key);
|
|
151
|
-
|
|
152
|
-
const availRaw =
|
|
153
|
-
nodeProp('slo-availability')?.value ?? diagramOptions['slo-availability'];
|
|
154
|
-
const latencyRaw =
|
|
155
|
-
nodeProp('slo-p90-latency-ms')?.value ??
|
|
156
|
-
diagramOptions['slo-p90-latency-ms'];
|
|
157
|
-
const marginRaw =
|
|
158
|
-
nodeProp('slo-warning-margin')?.value ??
|
|
159
|
-
diagramOptions['slo-warning-margin'];
|
|
160
|
-
|
|
161
|
-
const availParsed =
|
|
162
|
-
availRaw != null
|
|
163
|
-
? parseFloat(String(availRaw).replace('%', '')) / 100
|
|
164
|
-
: NaN;
|
|
165
|
-
const availThreshold = !isNaN(availParsed) ? availParsed : null;
|
|
166
|
-
const latencyParsed =
|
|
167
|
-
latencyRaw != null ? parseFloat(String(latencyRaw)) : NaN;
|
|
168
|
-
const latencyP90 = !isNaN(latencyParsed) ? latencyParsed : null;
|
|
169
|
-
const marginParsed =
|
|
170
|
-
marginRaw != null
|
|
171
|
-
? parseFloat(String(marginRaw).replace('%', '')) / 100
|
|
172
|
-
: NaN;
|
|
173
|
-
const warningMargin = !isNaN(marginParsed) ? marginParsed : 0.05;
|
|
174
|
-
|
|
175
|
-
if (availThreshold == null && latencyP90 == null) return null;
|
|
176
|
-
return { availThreshold, latencyP90, warningMargin };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** A row in the node card body. `inverted` renders as a colored pill with light text. */
|
|
180
|
-
interface NodeRow {
|
|
181
|
-
key: string;
|
|
182
|
-
value: string;
|
|
183
|
-
valueFill: string;
|
|
184
|
-
fontWeight: string;
|
|
185
|
-
inverted?: boolean;
|
|
186
|
-
invertedBg?: string;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Computed metric row (latency percentiles, availability, CB state). */
|
|
190
|
-
interface ComputedRow {
|
|
191
|
-
key: string;
|
|
192
|
-
value: string;
|
|
193
|
-
color?: string;
|
|
194
|
-
inverted?: boolean;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Animation constants
|
|
198
|
-
const FLOW_SPEED_MIN = 2.5; // seconds at max RPS
|
|
199
|
-
const FLOW_SPEED_MAX = 6; // seconds at min RPS
|
|
200
|
-
const PARTICLE_R = 5; // particle circle radius
|
|
201
|
-
const PARTICLE_COUNT_MIN = 1; // min particles per edge
|
|
202
|
-
const PARTICLE_COUNT_MAX = 4; // max particles per edge (at max RPS)
|
|
203
|
-
const NODE_PULSE_SPEED = 1.5; // seconds for warning pulse
|
|
204
|
-
const NODE_PULSE_OVERLOAD = 0.7; // seconds for overload pulse
|
|
205
|
-
|
|
206
|
-
// Reject particle constants
|
|
207
|
-
const REJECT_DROP_DISTANCE = 30; // px downward travel
|
|
208
|
-
const REJECT_DURATION_MIN = 1.5; // seconds per drop at max reject
|
|
209
|
-
const REJECT_DURATION_MAX = 3; // seconds per drop at min reject
|
|
210
|
-
const REJECT_COUNT_MIN = 1;
|
|
211
|
-
const REJECT_COUNT_MAX = 3;
|
|
212
|
-
|
|
213
|
-
// ============================================================
|
|
214
|
-
// Edge path generator
|
|
215
|
-
// ============================================================
|
|
216
|
-
|
|
217
|
-
type Pt = { x: number; y: number };
|
|
218
|
-
|
|
219
|
-
/** Produce an SVG path string for a sequence of waypoints.
|
|
220
|
-
* 2-point paths use curveBumpX/Y (nice S-curve).
|
|
221
|
-
* Multi-point obstacle-avoiding paths use CatmullRom for a smooth fit. */
|
|
222
|
-
function buildPathD(pts: Pt[], direction: 'LR' | 'TB'): string {
|
|
223
|
-
const gen = d3Shape
|
|
224
|
-
.line<Pt>()
|
|
225
|
-
.x((d) => d.x)
|
|
226
|
-
.y((d) => d.y);
|
|
227
|
-
if (pts.length <= 2) {
|
|
228
|
-
gen.curve(direction === 'TB' ? d3Shape.curveBumpY : d3Shape.curveBumpX);
|
|
229
|
-
} else {
|
|
230
|
-
gen.curve(d3Shape.curveCatmullRom.alpha(0.5));
|
|
231
|
-
}
|
|
232
|
-
return gen(pts) ?? '';
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
type Rect = { x: number; y: number; width: number; height: number };
|
|
236
|
-
|
|
237
|
-
/** Port-order exit and enter points for edges to eliminate fan-out crossings.
|
|
238
|
-
*
|
|
239
|
-
* For each node with ≥2 outgoing edges: sort edges by target perpendicular coord
|
|
240
|
-
* and assign source-border exit positions in the same order.
|
|
241
|
-
* For each node with ≥2 incoming edges: sort edges by source perpendicular coord
|
|
242
|
-
* and assign target-border enter positions in the same order.
|
|
243
|
-
*
|
|
244
|
-
* Returns two maps keyed by "sourceId:targetId":
|
|
245
|
-
* - srcPts: port-ordered exit point on the source border
|
|
246
|
-
* - tgtPts: port-ordered enter point on the target border */
|
|
247
|
-
function computePortPts(
|
|
248
|
-
edges: readonly InfraLayoutEdge[],
|
|
249
|
-
nodeMap: Map<string, InfraLayoutNode>,
|
|
250
|
-
direction: 'LR' | 'TB'
|
|
251
|
-
): { srcPts: Map<string, Pt>; tgtPts: Map<string, Pt> } {
|
|
252
|
-
const srcPts = new Map<string, Pt>();
|
|
253
|
-
const tgtPts = new Map<string, Pt>();
|
|
254
|
-
const PAD = 0.1; // keep ports 10% in from node edges
|
|
255
|
-
|
|
256
|
-
const activeEdges = edges.filter((e) => e.points.length > 0);
|
|
257
|
-
|
|
258
|
-
// ── Source (exit) port ordering ──────────────────────────────────────────
|
|
259
|
-
const bySource = new Map<string, InfraLayoutEdge[]>();
|
|
260
|
-
for (const e of activeEdges) {
|
|
261
|
-
if (!bySource.has(e.sourceId)) bySource.set(e.sourceId, []);
|
|
262
|
-
bySource.get(e.sourceId)!.push(e);
|
|
263
|
-
}
|
|
264
|
-
for (const [sourceId, es] of bySource) {
|
|
265
|
-
if (es.length < 2) continue;
|
|
266
|
-
const source = nodeMap.get(sourceId);
|
|
267
|
-
if (!source) continue;
|
|
268
|
-
const sorted = es
|
|
269
|
-
.map((e) => ({ e, t: nodeMap.get(e.targetId) }))
|
|
270
|
-
.filter(
|
|
271
|
-
(x): x is { e: InfraLayoutEdge; t: InfraLayoutNode } => x.t != null
|
|
272
|
-
)
|
|
273
|
-
.sort((a, b) => (direction === 'LR' ? a.t.y - b.t.y : a.t.x - b.t.x));
|
|
274
|
-
const n = sorted.length;
|
|
275
|
-
for (let i = 0; i < n; i++) {
|
|
276
|
-
const frac = n === 1 ? 0.5 : PAD + ((1 - 2 * PAD) * i) / (n - 1);
|
|
277
|
-
// In-bounds by loop guard.
|
|
278
|
-
const { e, t } = sorted[i]!;
|
|
279
|
-
const isBackward = direction === 'LR' ? t.x < source.x : t.y < source.y;
|
|
280
|
-
if (direction === 'LR') {
|
|
281
|
-
srcPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
282
|
-
x: isBackward
|
|
283
|
-
? source.x - source.width / 2
|
|
284
|
-
: source.x + source.width / 2,
|
|
285
|
-
y: source.y - source.height / 2 + frac * source.height,
|
|
286
|
-
});
|
|
287
|
-
} else {
|
|
288
|
-
srcPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
289
|
-
x: source.x - source.width / 2 + frac * source.width,
|
|
290
|
-
y: isBackward
|
|
291
|
-
? source.y - source.height / 2
|
|
292
|
-
: source.y + source.height / 2,
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ── Target (enter) port ordering ─────────────────────────────────────────
|
|
299
|
-
const byTarget = new Map<string, InfraLayoutEdge[]>();
|
|
300
|
-
for (const e of activeEdges) {
|
|
301
|
-
if (!byTarget.has(e.targetId)) byTarget.set(e.targetId, []);
|
|
302
|
-
byTarget.get(e.targetId)!.push(e);
|
|
303
|
-
}
|
|
304
|
-
for (const [targetId, es] of byTarget) {
|
|
305
|
-
if (es.length < 2) continue;
|
|
306
|
-
const target = nodeMap.get(targetId);
|
|
307
|
-
if (!target) continue;
|
|
308
|
-
const sorted = es
|
|
309
|
-
.map((e) => ({ e, s: nodeMap.get(e.sourceId) }))
|
|
310
|
-
.filter(
|
|
311
|
-
(x): x is { e: InfraLayoutEdge; s: InfraLayoutNode } => x.s != null
|
|
312
|
-
)
|
|
313
|
-
.sort((a, b) => (direction === 'LR' ? a.s.y - b.s.y : a.s.x - b.s.x));
|
|
314
|
-
const n = sorted.length;
|
|
315
|
-
for (let i = 0; i < n; i++) {
|
|
316
|
-
const frac = n === 1 ? 0.5 : PAD + ((1 - 2 * PAD) * i) / (n - 1);
|
|
317
|
-
// In-bounds by loop guard.
|
|
318
|
-
const { e, s } = sorted[i]!;
|
|
319
|
-
const isBackward = direction === 'LR' ? target.x < s.x : target.y < s.y;
|
|
320
|
-
if (direction === 'LR') {
|
|
321
|
-
tgtPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
322
|
-
x: isBackward
|
|
323
|
-
? target.x + target.width / 2
|
|
324
|
-
: target.x - target.width / 2,
|
|
325
|
-
y: target.y - target.height / 2 + frac * target.height,
|
|
326
|
-
});
|
|
327
|
-
} else {
|
|
328
|
-
tgtPts.set(`${e.sourceId}:${e.targetId}`, {
|
|
329
|
-
x: target.x - target.width / 2 + frac * target.width,
|
|
330
|
-
y: isBackward
|
|
331
|
-
? target.y + target.height / 2
|
|
332
|
-
: target.y - target.height / 2,
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
return { srcPts, tgtPts };
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Find the Y lane for routing around blocking obstacles.
|
|
342
|
-
* Prefers threading through Y gaps between blocking rects over routing above/below all.
|
|
343
|
-
* Picks the candidate closest to targetY to minimise arc size.
|
|
344
|
-
*
|
|
345
|
-
* Uses a tight merge expansion (MERGE_SLOP) so narrow inter-group gaps are preserved
|
|
346
|
-
* and can be threaded, rather than being swallowed into one large merged interval. */
|
|
347
|
-
function findRoutingLane(
|
|
348
|
-
blocking: Rect[],
|
|
349
|
-
targetY: number,
|
|
350
|
-
margin: number
|
|
351
|
-
): number {
|
|
352
|
-
// Use a small slop for merging so closely-spaced (but distinct) groups
|
|
353
|
-
// stay as separate intervals and the gap between them can be threaded.
|
|
354
|
-
const MERGE_SLOP = 4;
|
|
355
|
-
const sorted = [...blocking].sort(
|
|
356
|
-
(a, b) => a.y + a.height / 2 - (b.y + b.height / 2)
|
|
357
|
-
);
|
|
358
|
-
const merged: [number, number][] = [];
|
|
359
|
-
for (const r of sorted) {
|
|
360
|
-
const lo = r.y - MERGE_SLOP;
|
|
361
|
-
const hi = r.y + r.height + MERGE_SLOP;
|
|
362
|
-
// In-bounds: merged.length > 0 guarded on this branch.
|
|
363
|
-
if (merged.length && lo <= merged[merged.length - 1]![1]) {
|
|
364
|
-
merged[merged.length - 1]![1] = Math.max(
|
|
365
|
-
merged[merged.length - 1]![1],
|
|
366
|
-
hi
|
|
367
|
-
);
|
|
368
|
-
} else {
|
|
369
|
-
merged.push([lo, hi]);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
if (merged.length === 0) return targetY;
|
|
373
|
-
|
|
374
|
-
// Candidate lanes: above all, below all, mid-points of gaps wide enough to thread.
|
|
375
|
-
// MIN_GAP: allow narrow gaps (edge is ~1.5px, so even 10px clearance is fine).
|
|
376
|
-
const MIN_GAP = 10;
|
|
377
|
-
const candidates: number[] = [
|
|
378
|
-
// In-bounds: merged.length > 0 guarded above.
|
|
379
|
-
merged[0]![0] - margin, // above all blocking rects
|
|
380
|
-
merged[merged.length - 1]![1] + margin, // below all blocking rects
|
|
381
|
-
];
|
|
382
|
-
for (let i = 0; i < merged.length - 1; i++) {
|
|
383
|
-
// In-bounds by loop guard.
|
|
384
|
-
const gapLo = merged[i]![1];
|
|
385
|
-
const gapHi = merged[i + 1]![0];
|
|
386
|
-
if (gapHi - gapLo >= MIN_GAP) {
|
|
387
|
-
candidates.push((gapLo + gapHi) / 2); // thread through the gap
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Return the candidate closest to targetY (tightest arc)
|
|
392
|
-
return candidates.reduce(
|
|
393
|
-
// candidates[0] is a non-empty array (two seed values pushed above).
|
|
394
|
-
(best, c) => (Math.abs(c - targetY) < Math.abs(best - targetY) ? c : best),
|
|
395
|
-
candidates[0]!
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/** Check whether segment p1→p2 passes through (or has an endpoint inside) the rectangle. */
|
|
400
|
-
function segmentIntersectsRect(
|
|
401
|
-
p1: Pt,
|
|
402
|
-
p2: Pt,
|
|
403
|
-
rect: { x: number; y: number; width: number; height: number }
|
|
404
|
-
): boolean {
|
|
405
|
-
const { x: rx, y: ry, width: rw, height: rh } = rect;
|
|
406
|
-
const rr = rx + rw;
|
|
407
|
-
const rb = ry + rh;
|
|
408
|
-
// Point-in-rect check
|
|
409
|
-
const inRect = (p: Pt) => p.x >= rx && p.x <= rr && p.y >= ry && p.y <= rb;
|
|
410
|
-
if (inRect(p1) || inRect(p2)) return true;
|
|
411
|
-
// Segment bounding box vs rect
|
|
412
|
-
if (Math.max(p1.x, p2.x) < rx || Math.min(p1.x, p2.x) > rr) return false;
|
|
413
|
-
if (Math.max(p1.y, p2.y) < ry || Math.min(p1.y, p2.y) > rb) return false;
|
|
414
|
-
// Cross product sign helper (z-component of cross product)
|
|
415
|
-
const cross = (o: Pt, a: Pt, b: Pt) =>
|
|
416
|
-
(a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
|
417
|
-
// Does segment p1p2 cross segment a→b?
|
|
418
|
-
const crosses = (a: Pt, b: Pt) => {
|
|
419
|
-
const d1 = cross(a, b, p1);
|
|
420
|
-
const d2 = cross(a, b, p2);
|
|
421
|
-
const d3 = cross(p1, p2, a);
|
|
422
|
-
const d4 = cross(p1, p2, b);
|
|
423
|
-
return (
|
|
424
|
-
((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
|
425
|
-
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
|
|
426
|
-
);
|
|
427
|
-
};
|
|
428
|
-
const tl: Pt = { x: rx, y: ry };
|
|
429
|
-
const tr: Pt = { x: rr, y: ry };
|
|
430
|
-
const br: Pt = { x: rr, y: rb };
|
|
431
|
-
const bl: Pt = { x: rx, y: rb };
|
|
432
|
-
return (
|
|
433
|
-
crosses(tl, tr) || crosses(tr, br) || crosses(br, bl) || crosses(bl, tl)
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** Check whether the curveBumpX/Y S-curve from sc to tc intersects rect.
|
|
438
|
-
*
|
|
439
|
-
* curveBumpX (LR) stays near sc.y for the left half of the edge, transitions
|
|
440
|
-
* vertically at the midpoint, then stays near tc.y for the right half.
|
|
441
|
-
* Approximated as three Manhattan segments:
|
|
442
|
-
* sc → (midX, sc.y) → (midX, tc.y) → tc
|
|
443
|
-
*
|
|
444
|
-
* curveBumpY (TB) mirrors this on the other axis. */
|
|
445
|
-
function curveIntersectsRect(
|
|
446
|
-
sc: Pt,
|
|
447
|
-
tc: Pt,
|
|
448
|
-
rect: Rect,
|
|
449
|
-
direction: 'LR' | 'TB'
|
|
450
|
-
): boolean {
|
|
451
|
-
if (direction === 'LR') {
|
|
452
|
-
const midX = (sc.x + tc.x) / 2;
|
|
453
|
-
const m1: Pt = { x: midX, y: sc.y };
|
|
454
|
-
const m2: Pt = { x: midX, y: tc.y };
|
|
455
|
-
return (
|
|
456
|
-
segmentIntersectsRect(sc, m1, rect) ||
|
|
457
|
-
segmentIntersectsRect(m1, m2, rect) ||
|
|
458
|
-
segmentIntersectsRect(m2, tc, rect)
|
|
459
|
-
);
|
|
460
|
-
} else {
|
|
461
|
-
const midY = (sc.y + tc.y) / 2;
|
|
462
|
-
const m1: Pt = { x: sc.x, y: midY };
|
|
463
|
-
const m2: Pt = { x: tc.x, y: midY };
|
|
464
|
-
return (
|
|
465
|
-
segmentIntersectsRect(sc, m1, rect) ||
|
|
466
|
-
segmentIntersectsRect(m1, m2, rect) ||
|
|
467
|
-
segmentIntersectsRect(m2, tc, rect)
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/** Compute waypoints for an edge.
|
|
473
|
-
*
|
|
474
|
-
* - Backward edges (going against the primary layout direction) arc above/left
|
|
475
|
-
* of all layout content to avoid crossing everything.
|
|
476
|
-
* - Forward edges that cross a group bounding box or an unrelated node arc
|
|
477
|
-
* above or below the collective blocking region (gap-aware Y routing).
|
|
478
|
-
* - Clear forward edges use a direct 2-point path (curveBumpX/Y S-curve). */
|
|
479
|
-
function edgeWaypoints(
|
|
480
|
-
source: InfraLayoutNode,
|
|
481
|
-
target: InfraLayoutNode,
|
|
482
|
-
groups: readonly InfraLayoutGroup[],
|
|
483
|
-
nodes: readonly InfraLayoutNode[],
|
|
484
|
-
direction: 'LR' | 'TB',
|
|
485
|
-
margin = 30,
|
|
486
|
-
srcExitPt?: Pt, // port-ordered exit point on source border
|
|
487
|
-
tgtEnterPt?: Pt // port-ordered enter point on target border
|
|
488
|
-
): Pt[] {
|
|
489
|
-
const sc: Pt = { x: source.x, y: source.y };
|
|
490
|
-
const tc: Pt = { x: target.x, y: target.y };
|
|
491
|
-
|
|
492
|
-
// ── Backward edge handling ───────────────────────────────────────────────
|
|
493
|
-
// curveBumpX/Y on a backward edge creates a loop. Route around obstacles
|
|
494
|
-
// in the backward path's axis band for a tight arc (not global min/max).
|
|
495
|
-
const isBackward = direction === 'LR' ? tc.x < sc.x : tc.y < sc.y;
|
|
496
|
-
if (isBackward) {
|
|
497
|
-
if (direction === 'LR') {
|
|
498
|
-
// Collect group/node rects that overlap the X band [tc.x, sc.x]
|
|
499
|
-
const xBandObs: Rect[] = [];
|
|
500
|
-
for (const g of groups) {
|
|
501
|
-
if (g.x + g.width < tc.x - margin || g.x > sc.x + margin) continue;
|
|
502
|
-
xBandObs.push({ x: g.x, y: g.y, width: g.width, height: g.height });
|
|
503
|
-
}
|
|
504
|
-
for (const n of nodes) {
|
|
505
|
-
if (n.id === source.id || n.id === target.id) continue;
|
|
506
|
-
const nLeft = n.x - n.width / 2;
|
|
507
|
-
const nRight = n.x + n.width / 2;
|
|
508
|
-
if (nRight < tc.x - margin || nLeft > sc.x + margin) continue;
|
|
509
|
-
xBandObs.push({
|
|
510
|
-
x: nLeft,
|
|
511
|
-
y: n.y - n.height / 2,
|
|
512
|
-
width: n.width,
|
|
513
|
-
height: n.height,
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
const midY = (sc.y + tc.y) / 2;
|
|
517
|
-
const routeY =
|
|
518
|
-
xBandObs.length > 0 ? findRoutingLane(xBandObs, midY, margin) : midY;
|
|
519
|
-
const exitBorder: Pt =
|
|
520
|
-
srcExitPt ?? nodeBorderPoint(source, { x: sc.x, y: routeY });
|
|
521
|
-
const exitPt: Pt = { x: exitBorder.x, y: routeY };
|
|
522
|
-
const enterPt: Pt = { x: tc.x, y: routeY };
|
|
523
|
-
const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
|
|
524
|
-
return srcExitPt
|
|
525
|
-
? [srcExitPt, exitPt, enterPt, tp]
|
|
526
|
-
: [exitBorder, exitPt, enterPt, tp];
|
|
527
|
-
} else {
|
|
528
|
-
// TB backward: collect obstacles in Y band [tc.y, sc.y]; find X lane
|
|
529
|
-
const yBandObs: Rect[] = [];
|
|
530
|
-
for (const g of groups) {
|
|
531
|
-
if (g.y + g.height < tc.y - margin || g.y > sc.y + margin) continue;
|
|
532
|
-
yBandObs.push({ x: g.x, y: g.y, width: g.width, height: g.height });
|
|
533
|
-
}
|
|
534
|
-
for (const n of nodes) {
|
|
535
|
-
if (n.id === source.id || n.id === target.id) continue;
|
|
536
|
-
const nTop = n.y - n.height / 2;
|
|
537
|
-
const nBot = n.y + n.height / 2;
|
|
538
|
-
if (nBot < tc.y - margin || nTop > sc.y + margin) continue;
|
|
539
|
-
yBandObs.push({
|
|
540
|
-
x: n.x - n.width / 2,
|
|
541
|
-
y: nTop,
|
|
542
|
-
width: n.width,
|
|
543
|
-
height: n.height,
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
// Rotate axes so findRoutingLane (which works in Y) resolves an X lane
|
|
547
|
-
const rotated = yBandObs.map((r) => ({
|
|
548
|
-
x: r.y,
|
|
549
|
-
y: r.x,
|
|
550
|
-
width: r.height,
|
|
551
|
-
height: r.width,
|
|
552
|
-
}));
|
|
553
|
-
const midX = (sc.x + tc.x) / 2;
|
|
554
|
-
const routeX =
|
|
555
|
-
rotated.length > 0 ? findRoutingLane(rotated, midX, margin) : midX;
|
|
556
|
-
const exitPt: Pt = srcExitPt ?? { x: routeX, y: sc.y };
|
|
557
|
-
const enterPt: Pt = { x: routeX, y: tc.y };
|
|
558
|
-
return [
|
|
559
|
-
srcExitPt ?? nodeBorderPoint(source, exitPt),
|
|
560
|
-
exitPt,
|
|
561
|
-
enterPt,
|
|
562
|
-
tgtEnterPt ?? nodeBorderPoint(target, enterPt),
|
|
563
|
-
];
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// ── Forward edge: obstacle avoidance (groups + individual nodes) ─────────
|
|
568
|
-
const blocking: { x: number; y: number; width: number; height: number }[] =
|
|
569
|
-
[];
|
|
570
|
-
const blockingGroupIds = new Set<string>();
|
|
571
|
-
|
|
572
|
-
// Use actual path endpoints (port-ordered) for more accurate blocking detection.
|
|
573
|
-
// Node centers underestimate midX/midY, causing nearby obstacles to go undetected.
|
|
574
|
-
const pathSrc: Pt = srcExitPt ?? sc;
|
|
575
|
-
const pathTgt: Pt = tgtEnterPt ?? tc;
|
|
576
|
-
|
|
577
|
-
// Groups (excluding source/target groups)
|
|
578
|
-
for (const g of groups) {
|
|
579
|
-
if (g.id === source.groupId || g.id === target.groupId) continue;
|
|
580
|
-
const gRect: Rect = { x: g.x, y: g.y, width: g.width, height: g.height };
|
|
581
|
-
if (curveIntersectsRect(pathSrc, pathTgt, gRect, direction)) {
|
|
582
|
-
blocking.push(gRect);
|
|
583
|
-
blockingGroupIds.add(g.id);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// Individual nodes not already covered by a blocking group rect
|
|
588
|
-
for (const n of nodes) {
|
|
589
|
-
if (n.id === source.id || n.id === target.id) continue;
|
|
590
|
-
// Skip nodes in source/target groups (routing around the group handles them)
|
|
591
|
-
if (
|
|
592
|
-
n.groupId &&
|
|
593
|
-
(n.groupId === source.groupId || n.groupId === target.groupId)
|
|
594
|
-
)
|
|
595
|
-
continue;
|
|
596
|
-
// Skip nodes inside a group whose bounding box is already blocking
|
|
597
|
-
if (n.groupId && blockingGroupIds.has(n.groupId)) continue;
|
|
598
|
-
const nodeRect: Rect = {
|
|
599
|
-
x: n.x - n.width / 2,
|
|
600
|
-
y: n.y - n.height / 2,
|
|
601
|
-
width: n.width,
|
|
602
|
-
height: n.height,
|
|
603
|
-
};
|
|
604
|
-
if (curveIntersectsRect(pathSrc, pathTgt, nodeRect, direction)) {
|
|
605
|
-
blocking.push(nodeRect);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
if (blocking.length === 0) {
|
|
610
|
-
// Direct 2-point path — use port-ordered entry/exit points when provided.
|
|
611
|
-
const sp = srcExitPt ?? nodeBorderPoint(source, tc);
|
|
612
|
-
const tp = tgtEnterPt ?? nodeBorderPoint(target, sp);
|
|
613
|
-
return [sp, tp];
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const obsLeft = Math.min(...blocking.map((o) => o.x));
|
|
617
|
-
const obsRight = Math.max(...blocking.map((o) => o.x + o.width));
|
|
618
|
-
|
|
619
|
-
const routeY = findRoutingLane(blocking, tc.y, margin);
|
|
620
|
-
|
|
621
|
-
// Clamp exit/enter X to [sc.x, tc.x] for LR so the path never reverses
|
|
622
|
-
// direction when an obstacle's bounding box extends past source or target.
|
|
623
|
-
const exitX =
|
|
624
|
-
direction === 'LR' ? Math.max(sc.x, obsLeft - margin) : obsLeft - margin;
|
|
625
|
-
const enterX =
|
|
626
|
-
direction === 'LR' ? Math.min(tc.x, obsRight + margin) : obsRight + margin;
|
|
627
|
-
const exitPt: Pt = { x: exitX, y: routeY };
|
|
628
|
-
const enterPt: Pt = { x: enterX, y: routeY };
|
|
629
|
-
|
|
630
|
-
const tp = tgtEnterPt ?? nodeBorderPoint(target, enterPt);
|
|
631
|
-
|
|
632
|
-
if (srcExitPt) {
|
|
633
|
-
return [srcExitPt, exitPt, enterPt, tp];
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
return [nodeBorderPoint(source, exitPt), exitPt, enterPt, tp];
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/** Compute the point on a node's border closest to an external target point. */
|
|
640
|
-
function nodeBorderPoint(
|
|
641
|
-
node: InfraLayoutNode,
|
|
642
|
-
target: { x: number; y: number }
|
|
643
|
-
): { x: number; y: number } {
|
|
644
|
-
const hw = node.width / 2;
|
|
645
|
-
const hh = node.height / 2;
|
|
646
|
-
const dx = target.x - node.x;
|
|
647
|
-
const dy = target.y - node.y;
|
|
648
|
-
if (dx === 0 && dy === 0) return { x: node.x + hw, y: node.y };
|
|
649
|
-
|
|
650
|
-
// Scale factor to reach the bounding box edge
|
|
651
|
-
const sx = hw / Math.abs(dx || 1);
|
|
652
|
-
const sy = hh / Math.abs(dy || 1);
|
|
653
|
-
const s = Math.min(sx, sy);
|
|
654
|
-
|
|
655
|
-
return { x: node.x + dx * s, y: node.y + dy * s };
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/** Map RPS to animation duration — higher RPS = faster (shorter duration). */
|
|
659
|
-
function flowDuration(rps: number, maxRps: number): number {
|
|
660
|
-
if (maxRps <= 0) return FLOW_SPEED_MAX;
|
|
661
|
-
const t = Math.min(rps / maxRps, 1);
|
|
662
|
-
return FLOW_SPEED_MAX - t * (FLOW_SPEED_MAX - FLOW_SPEED_MIN);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/** Map RPS to particle count — more traffic = more particles. */
|
|
666
|
-
function particleCount(rps: number, maxRps: number): number {
|
|
667
|
-
if (maxRps <= 0) return PARTICLE_COUNT_MIN;
|
|
668
|
-
const t = Math.min(rps / maxRps, 1);
|
|
669
|
-
return Math.round(
|
|
670
|
-
PARTICLE_COUNT_MIN + t * (PARTICLE_COUNT_MAX - PARTICLE_COUNT_MIN)
|
|
671
|
-
);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/** Determine if a node is in warning state (>70% capacity but not overloaded). */
|
|
675
|
-
function isWarning(node: InfraLayoutNode): boolean {
|
|
676
|
-
if (node.overloaded || node.isEdge) return false;
|
|
677
|
-
// Serverless: concurrency-based capacity
|
|
678
|
-
const concurrencyProp = node.properties.find((p) => p.key === 'concurrency');
|
|
679
|
-
if (concurrencyProp) {
|
|
680
|
-
const concurrency = Number(concurrencyProp.value);
|
|
681
|
-
const durationProp = node.properties.find((p) => p.key === 'duration-ms');
|
|
682
|
-
const durationMs = durationProp ? Number(durationProp.value) : 100;
|
|
683
|
-
const cap = concurrency / (durationMs / 1000);
|
|
684
|
-
return cap > 0 && node.computedRps / cap > 0.7;
|
|
685
|
-
}
|
|
686
|
-
// Traditional: max-rps * instances
|
|
687
|
-
const maxRps = node.properties.find((p) => p.key === 'max-rps');
|
|
688
|
-
if (!maxRps) return false;
|
|
689
|
-
const cap = Number(maxRps.value) * node.computedInstances;
|
|
690
|
-
return cap > 0 && node.computedRps / cap > 0.7;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// ============================================================
|
|
694
|
-
// Helpers
|
|
695
|
-
// ============================================================
|
|
696
|
-
|
|
697
|
-
/** Display names for behavior property keys. Aligned with DSL property names. */
|
|
698
|
-
const PROP_DISPLAY: Record<string, string> = {
|
|
699
|
-
'cache-hit': 'cache hit',
|
|
700
|
-
'firewall-block': 'firewall block',
|
|
701
|
-
'ratelimit-rps': 'rate limit RPS',
|
|
702
|
-
'latency-ms': 'latency',
|
|
703
|
-
uptime: 'uptime',
|
|
704
|
-
instances: 'instances',
|
|
705
|
-
'max-rps': 'max RPS',
|
|
706
|
-
'cb-error-threshold': 'CB error threshold',
|
|
707
|
-
'cb-latency-threshold-ms': 'CB latency threshold',
|
|
708
|
-
concurrency: 'concurrency',
|
|
709
|
-
'duration-ms': 'duration',
|
|
710
|
-
'cold-start-ms': 'cold start',
|
|
711
|
-
buffer: 'buffer',
|
|
712
|
-
'drain-rate': 'drain rate',
|
|
713
|
-
'retention-hours': 'retention',
|
|
714
|
-
partitions: 'partitions',
|
|
715
|
-
};
|
|
716
|
-
|
|
717
|
-
const DESC_MAX_CHARS = 120;
|
|
718
|
-
/** Pixel-width cap for description lines, derived from the legacy char cap at META_FONT_SIZE. */
|
|
719
|
-
const DESC_MAX_WIDTH = DESC_MAX_CHARS * CHAR_WIDTH_RATIO * META_FONT_SIZE;
|
|
720
|
-
|
|
721
|
-
/** Truncate description text to fit DESC_MAX_WIDTH at META_FONT_SIZE. */
|
|
722
|
-
function truncateDesc(text: string): string {
|
|
723
|
-
return truncateText(text, META_FONT_SIZE, DESC_MAX_WIDTH);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/** Keys whose values are RPS counts and should be formatted like RPS. */
|
|
727
|
-
const RPS_FORMAT_KEYS = new Set(['max-rps', 'ratelimit-rps']);
|
|
728
|
-
|
|
729
|
-
/** Keys whose values are milliseconds and should show the "ms" suffix. */
|
|
730
|
-
const MS_FORMAT_KEYS = new Set([
|
|
731
|
-
'latency-ms',
|
|
732
|
-
'cb-latency-threshold-ms',
|
|
733
|
-
'duration-ms',
|
|
734
|
-
'cold-start-ms',
|
|
735
|
-
]);
|
|
736
|
-
|
|
737
|
-
/** Keys whose values are percentages and should show the "%" suffix. */
|
|
738
|
-
const PCT_FORMAT_KEYS = new Set([
|
|
739
|
-
'cache-hit',
|
|
740
|
-
'firewall-block',
|
|
741
|
-
'uptime',
|
|
742
|
-
'cb-error-threshold',
|
|
743
|
-
]);
|
|
744
|
-
|
|
745
|
-
/** Compute SLO color for a p90 latency value against the configured threshold.
|
|
746
|
-
* Callers must guard slo.latencyP90 != null before calling. */
|
|
747
|
-
function sloLatencyColor(p90: number, slo: NodeSlo): string {
|
|
748
|
-
const t = slo.latencyP90 ?? 0;
|
|
749
|
-
if (t === 0) return COLOR_HEALTHY; // no meaningful threshold — treat as healthy
|
|
750
|
-
const m = slo.warningMargin;
|
|
751
|
-
return p90 > t
|
|
752
|
-
? COLOR_OVERLOADED
|
|
753
|
-
: p90 > t * (1 - m)
|
|
754
|
-
? COLOR_WARNING
|
|
755
|
-
: COLOR_HEALTHY;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/** Computed metric rows (latency percentiles, uptime, availability, CB state) shown after declared props. */
|
|
759
|
-
function getComputedRows(
|
|
760
|
-
node: InfraLayoutNode,
|
|
761
|
-
expanded: boolean,
|
|
762
|
-
slo?: NodeSlo | null
|
|
763
|
-
): ComputedRow[] {
|
|
764
|
-
const rows: ComputedRow[] = [];
|
|
765
|
-
|
|
766
|
-
// Serverless instances: demand vs concurrency limit
|
|
767
|
-
if (node.computedConcurrentInvocations > 0) {
|
|
768
|
-
const concurrency = getNodeNumProp(node, 'concurrency', 0);
|
|
769
|
-
const demand = node.computedConcurrentInvocations;
|
|
770
|
-
const ratio = concurrency > 0 ? demand / concurrency : 0;
|
|
771
|
-
const color =
|
|
772
|
-
ratio > 1 ? COLOR_OVERLOADED : ratio > 0.7 ? COLOR_WARNING : undefined;
|
|
773
|
-
const value =
|
|
774
|
-
concurrency > 0
|
|
775
|
-
? `${formatCount(demand)} / ${formatCount(concurrency)}`
|
|
776
|
-
: `${formatCount(demand)}`;
|
|
777
|
-
rows.push({
|
|
778
|
-
key: 'instances',
|
|
779
|
-
value,
|
|
780
|
-
...(color !== undefined && { color }),
|
|
781
|
-
inverted: color != null,
|
|
782
|
-
});
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
const p = node.computedLatencyPercentiles;
|
|
786
|
-
if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) {
|
|
787
|
-
if (expanded) {
|
|
788
|
-
rows.push({ key: 'p50', value: formatMsShort(p.p50) });
|
|
789
|
-
if (slo?.latencyP90 != null) {
|
|
790
|
-
const color = sloLatencyColor(p.p90, slo);
|
|
791
|
-
const p90Value =
|
|
792
|
-
color !== COLOR_HEALTHY
|
|
793
|
-
? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
|
|
794
|
-
: formatMsShort(p.p90);
|
|
795
|
-
rows.push({
|
|
796
|
-
key: 'p90',
|
|
797
|
-
value: p90Value,
|
|
798
|
-
color,
|
|
799
|
-
inverted: color !== COLOR_HEALTHY,
|
|
800
|
-
});
|
|
801
|
-
} else {
|
|
802
|
-
rows.push({ key: 'p90', value: formatMsShort(p.p90) });
|
|
803
|
-
}
|
|
804
|
-
rows.push({ key: 'p99', value: formatMsShort(p.p99) });
|
|
805
|
-
} else if (p.p90 > 0) {
|
|
806
|
-
// Collapsed: show p90 (with SLO color if configured) instead of p99
|
|
807
|
-
if (slo?.latencyP90 != null) {
|
|
808
|
-
const color = sloLatencyColor(p.p90, slo);
|
|
809
|
-
const p90Value =
|
|
810
|
-
color !== COLOR_HEALTHY
|
|
811
|
-
? `${formatMsShort(p.p90)} / ${formatMsShort(slo.latencyP90!)}`
|
|
812
|
-
: formatMsShort(p.p90);
|
|
813
|
-
rows.push({
|
|
814
|
-
key: 'p90',
|
|
815
|
-
value: p90Value,
|
|
816
|
-
color,
|
|
817
|
-
inverted: color !== COLOR_HEALTHY,
|
|
818
|
-
});
|
|
819
|
-
} else {
|
|
820
|
-
rows.push({ key: 'p90', value: formatMsShort(p.p90) });
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
// Computed (cumulative) uptime — only show when it differs from the declared node uptime.
|
|
825
|
-
// On the edge node this is the system-wide uptime; on other nodes it's the path product.
|
|
826
|
-
// Label as "eff. uptime" to distinguish from the declared "uptime" property row.
|
|
827
|
-
if (node.computedUptime < 1) {
|
|
828
|
-
const declaredUptime = node.properties.find((p) => p.key === 'uptime');
|
|
829
|
-
const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
|
|
830
|
-
const differs = Math.abs(node.computedUptime - declaredVal) > 0.000001;
|
|
831
|
-
if (differs || node.isEdge) {
|
|
832
|
-
rows.push({
|
|
833
|
-
key: 'eff. uptime',
|
|
834
|
-
value: formatUptimeShort(node.computedUptime),
|
|
835
|
-
});
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
if (node.computedAvailability < 1) {
|
|
839
|
-
let color: string | undefined;
|
|
840
|
-
if (slo?.availThreshold != null) {
|
|
841
|
-
const t = slo.availThreshold;
|
|
842
|
-
const m = slo.warningMargin;
|
|
843
|
-
if (node.computedAvailability < t) color = COLOR_OVERLOADED;
|
|
844
|
-
else if (node.computedAvailability < Math.min(1, t + m))
|
|
845
|
-
color = COLOR_WARNING;
|
|
846
|
-
else color = COLOR_HEALTHY;
|
|
847
|
-
} else {
|
|
848
|
-
color =
|
|
849
|
-
node.computedAvailability < 0.95
|
|
850
|
-
? COLOR_OVERLOADED
|
|
851
|
-
: node.computedAvailability < 0.99
|
|
852
|
-
? COLOR_WARNING
|
|
853
|
-
: undefined;
|
|
854
|
-
}
|
|
855
|
-
rows.push({
|
|
856
|
-
key: 'availability',
|
|
857
|
-
value: formatUptimeShort(node.computedAvailability),
|
|
858
|
-
...(color !== undefined && { color }),
|
|
859
|
-
inverted: color != null && color !== COLOR_HEALTHY,
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
// Circuit breaker state — show when a CB is configured and open
|
|
863
|
-
if (node.computedCbState === 'open') {
|
|
864
|
-
rows.push({
|
|
865
|
-
key: 'CB',
|
|
866
|
-
value: 'OPEN',
|
|
867
|
-
color: COLOR_OVERLOADED,
|
|
868
|
-
inverted: true,
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
// Queue computed rows: lag and overflow
|
|
872
|
-
if (node.queueMetrics) {
|
|
873
|
-
const { fillRate, timeToOverflow } = node.queueMetrics;
|
|
874
|
-
if (fillRate > 0) {
|
|
875
|
-
rows.push({
|
|
876
|
-
key: 'lag',
|
|
877
|
-
value: `${formatCount(Math.round(fillRate))} msg/s`,
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
if (fillRate > 0 && timeToOverflow < Infinity) {
|
|
881
|
-
const overflowColor =
|
|
882
|
-
timeToOverflow < 60 ? COLOR_OVERLOADED : COLOR_WARNING;
|
|
883
|
-
rows.push({
|
|
884
|
-
key: 'overflow',
|
|
885
|
-
value: `~${Math.round(timeToOverflow)}s`,
|
|
886
|
-
color: overflowColor,
|
|
887
|
-
inverted: timeToOverflow < 60,
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
return rows;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
function formatCount(n: number): string {
|
|
895
|
-
if (n >= 1000000)
|
|
896
|
-
return `${(n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1)}M`;
|
|
897
|
-
if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
|
|
898
|
-
return String(n);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
function formatMsShort(ms: number): string {
|
|
902
|
-
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
|
903
|
-
return `${Math.round(ms)}ms`;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
function formatUptimeShort(fraction: number): string {
|
|
907
|
-
const pct = fraction * 100;
|
|
908
|
-
if (pct >= 99.99) return '99.99%';
|
|
909
|
-
if (pct >= 99.9) return `${pct.toFixed(2)}%`;
|
|
910
|
-
if (pct >= 99) return `${pct.toFixed(1)}%`;
|
|
911
|
-
return `${pct.toFixed(1)}%`;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
/** Properties shown as key-value rows inside the node card. */
|
|
915
|
-
function getDisplayProps(
|
|
916
|
-
node: InfraLayoutNode,
|
|
917
|
-
expanded: boolean,
|
|
918
|
-
diagramOptions?: Record<string, string>
|
|
919
|
-
): { key: string; displayKey: string; value: string }[] {
|
|
920
|
-
if (node.isEdge) return [];
|
|
921
|
-
const rows: { key: string; displayKey: string; value: string }[] = [];
|
|
922
|
-
for (const p of node.properties) {
|
|
923
|
-
const displayKey = PROP_DISPLAY[p.key];
|
|
924
|
-
if (!displayKey) continue;
|
|
925
|
-
// Rate limit and max-rps are already shown in the RPS denominator —
|
|
926
|
-
// only show the dedicated row when the node is expanded (selected).
|
|
927
|
-
if (p.key === 'ratelimit-rps' && !expanded) continue;
|
|
928
|
-
if (p.key === 'max-rps' && !expanded) continue;
|
|
929
|
-
if (p.key === 'concurrency' && !expanded) continue;
|
|
930
|
-
// Format values with appropriate units
|
|
931
|
-
if (RPS_FORMAT_KEYS.has(p.key)) {
|
|
932
|
-
const num =
|
|
933
|
-
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
934
|
-
rows.push({
|
|
935
|
-
key: p.key,
|
|
936
|
-
displayKey,
|
|
937
|
-
value: isNaN(num) ? String(p.value) : formatRpsShort(num),
|
|
938
|
-
});
|
|
939
|
-
} else if (MS_FORMAT_KEYS.has(p.key)) {
|
|
940
|
-
const num =
|
|
941
|
-
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
942
|
-
rows.push({
|
|
943
|
-
key: p.key,
|
|
944
|
-
displayKey,
|
|
945
|
-
value: isNaN(num) ? String(p.value) : formatMsShort(num),
|
|
946
|
-
});
|
|
947
|
-
} else if (PCT_FORMAT_KEYS.has(p.key)) {
|
|
948
|
-
const num =
|
|
949
|
-
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
950
|
-
rows.push({
|
|
951
|
-
key: p.key,
|
|
952
|
-
displayKey,
|
|
953
|
-
value: isNaN(num) ? String(p.value) : `${num}%`,
|
|
954
|
-
});
|
|
955
|
-
} else if (p.key === 'buffer') {
|
|
956
|
-
const num =
|
|
957
|
-
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
958
|
-
rows.push({
|
|
959
|
-
key: p.key,
|
|
960
|
-
displayKey,
|
|
961
|
-
value: isNaN(num) ? String(p.value) : formatCount(num),
|
|
962
|
-
});
|
|
963
|
-
} else if (p.key === 'drain-rate') {
|
|
964
|
-
const num =
|
|
965
|
-
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
966
|
-
rows.push({
|
|
967
|
-
key: p.key,
|
|
968
|
-
displayKey,
|
|
969
|
-
value: isNaN(num) ? String(p.value) : `${formatRpsShort(num)}/s`,
|
|
970
|
-
});
|
|
971
|
-
} else if (p.key === 'retention-hours') {
|
|
972
|
-
const num =
|
|
973
|
-
typeof p.value === 'number' ? p.value : parseFloat(String(p.value));
|
|
974
|
-
rows.push({
|
|
975
|
-
key: p.key,
|
|
976
|
-
displayKey,
|
|
977
|
-
value: isNaN(num) ? String(p.value) : `${num}h`,
|
|
978
|
-
});
|
|
979
|
-
} else {
|
|
980
|
-
rows.push({ key: p.key, displayKey, value: String(p.value) });
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
// Inject diagram-level defaults for properties the node doesn't explicitly declare
|
|
984
|
-
if (diagramOptions) {
|
|
985
|
-
const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
|
|
986
|
-
const hasUptime = node.properties.some((p) => p.key === 'uptime');
|
|
987
|
-
const isServerlessNode = node.properties.some(
|
|
988
|
-
(p) => p.key === 'concurrency'
|
|
989
|
-
);
|
|
990
|
-
const defaultLatency =
|
|
991
|
-
parseFloat(diagramOptions['default-latency-ms'] ?? '') || 0;
|
|
992
|
-
const defaultUptime =
|
|
993
|
-
parseFloat(diagramOptions['default-uptime'] ?? '') || 0;
|
|
994
|
-
if (!hasLatency && !isServerlessNode && defaultLatency > 0) {
|
|
995
|
-
rows.push({
|
|
996
|
-
key: 'latency-ms',
|
|
997
|
-
displayKey: 'latency',
|
|
998
|
-
value: formatMsShort(defaultLatency),
|
|
999
|
-
});
|
|
1000
|
-
}
|
|
1001
|
-
if (!hasUptime && defaultUptime > 0 && defaultUptime < 100) {
|
|
1002
|
-
rows.push({
|
|
1003
|
-
key: 'uptime',
|
|
1004
|
-
displayKey: 'uptime',
|
|
1005
|
-
value: `${defaultUptime}%`,
|
|
1006
|
-
});
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
return rows;
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
/** RPS value without "rps" suffix — for key-value rows where the key already says "RPS". */
|
|
1013
|
-
function formatRpsShort(rps: number): string {
|
|
1014
|
-
if (rps >= 1000) return `${(rps / 1000).toFixed(1)}k`;
|
|
1015
|
-
return `${Math.round(rps)}`;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
/** Compute the worst severity across all row-producing conditions for a node.
|
|
1019
|
-
* Returns 'overloaded' (red), 'warning' (yellow), 'healthy' (green), or 'normal'. */
|
|
1020
|
-
function worstNodeSeverity(
|
|
1021
|
-
node: InfraLayoutNode,
|
|
1022
|
-
slo?: NodeSlo | null
|
|
1023
|
-
): 'overloaded' | 'warning' | 'healthy' | 'normal' {
|
|
1024
|
-
let worst: 'overloaded' | 'warning' | 'normal' = 'normal';
|
|
1025
|
-
const upgrade = (s: 'overloaded' | 'warning') => {
|
|
1026
|
-
if (s === 'overloaded') worst = 'overloaded';
|
|
1027
|
-
else if (worst !== 'overloaded') worst = 'warning';
|
|
1028
|
-
};
|
|
1029
|
-
|
|
1030
|
-
// Collapsed group nodes: incorporate child health state
|
|
1031
|
-
if (node.childHealthState === 'overloaded') upgrade('overloaded');
|
|
1032
|
-
else if (node.childHealthState === 'warning') upgrade('warning');
|
|
1033
|
-
|
|
1034
|
-
// RPS-based: overloaded, rate-limited, or >70% capacity
|
|
1035
|
-
if (node.overloaded) upgrade('overloaded');
|
|
1036
|
-
if (node.rateLimited) upgrade('warning');
|
|
1037
|
-
if (isWarning(node)) upgrade('warning');
|
|
1038
|
-
|
|
1039
|
-
// Availability — SLO threshold if declared, otherwise hardcoded fallback
|
|
1040
|
-
if (slo?.availThreshold != null) {
|
|
1041
|
-
const t = slo.availThreshold;
|
|
1042
|
-
const m = slo.warningMargin;
|
|
1043
|
-
if (node.computedAvailability < t) upgrade('overloaded');
|
|
1044
|
-
else if (node.computedAvailability < Math.min(1, t + m)) upgrade('warning');
|
|
1045
|
-
// else: in green zone — handled after loop
|
|
1046
|
-
} else {
|
|
1047
|
-
if (node.computedAvailability < 0.95) upgrade('overloaded');
|
|
1048
|
-
else if (node.computedAvailability < 0.99) upgrade('warning');
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
// p90 Latency SLO
|
|
1052
|
-
if (slo?.latencyP90 != null) {
|
|
1053
|
-
const t = slo.latencyP90;
|
|
1054
|
-
const m = slo.warningMargin;
|
|
1055
|
-
const p90 = node.computedLatencyPercentiles.p90;
|
|
1056
|
-
if (p90 > t) upgrade('overloaded');
|
|
1057
|
-
else if (p90 > t * (1 - m)) upgrade('warning');
|
|
1058
|
-
// else: in green zone
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
// Circuit breaker open
|
|
1062
|
-
if (node.computedCbState === 'open') upgrade('overloaded');
|
|
1063
|
-
|
|
1064
|
-
// Queue state: filling → warning, imminent overflow → overloaded
|
|
1065
|
-
if (node.queueMetrics && node.queueMetrics.fillRate > 0) {
|
|
1066
|
-
if (node.queueMetrics.timeToOverflow < 60) upgrade('overloaded');
|
|
1067
|
-
else upgrade('warning');
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
// Rate-limit row (pre-rate-limit RPS vs configured limit)
|
|
1071
|
-
if (!node.isEdge) {
|
|
1072
|
-
const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
|
|
1073
|
-
if (nodeRateLimit > 0 && node.computedRps > 0) {
|
|
1074
|
-
let preRl = node.computedRps;
|
|
1075
|
-
const ch = getNodeNumProp(node, 'cache-hit', 0);
|
|
1076
|
-
if (ch > 0) preRl *= (100 - ch) / 100;
|
|
1077
|
-
const fw = getNodeNumProp(node, 'firewall-block', 0);
|
|
1078
|
-
if (fw > 0) preRl *= (100 - fw) / 100;
|
|
1079
|
-
if (preRl > nodeRateLimit) upgrade('overloaded');
|
|
1080
|
-
else if (preRl > nodeRateLimit * 0.8) upgrade('warning');
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
// Healthy: SLO declared AND all checks are in the green zone
|
|
1085
|
-
if (worst === 'normal' && slo != null) {
|
|
1086
|
-
const availGreen =
|
|
1087
|
-
slo.availThreshold == null ||
|
|
1088
|
-
node.computedAvailability >=
|
|
1089
|
-
Math.min(1, slo.availThreshold + slo.warningMargin);
|
|
1090
|
-
const latencyGreen =
|
|
1091
|
-
slo.latencyP90 == null ||
|
|
1092
|
-
node.computedLatencyPercentiles.p90 <=
|
|
1093
|
-
slo.latencyP90 * (1 - slo.warningMargin);
|
|
1094
|
-
if (availGreen && latencyGreen) return 'healthy';
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
return worst;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function nodeColor(
|
|
1101
|
-
_node: InfraLayoutNode,
|
|
1102
|
-
palette: PaletteColors,
|
|
1103
|
-
isDark: boolean,
|
|
1104
|
-
severity: ReturnType<typeof worstNodeSeverity>,
|
|
1105
|
-
solid?: boolean
|
|
1106
|
-
): {
|
|
1107
|
-
fill: string;
|
|
1108
|
-
stroke: string;
|
|
1109
|
-
textFill: string;
|
|
1110
|
-
} {
|
|
1111
|
-
// Severity fills: canonical 25% tint via shapeFill() (TD-3).
|
|
1112
|
-
// Fills are visibly LOUDER than pre-spec 8-20% color; combined with
|
|
1113
|
-
// the 2x OVERLOAD_STROKE_WIDTH, severity reads cleanly without a badge.
|
|
1114
|
-
if (severity === 'overloaded') {
|
|
1115
|
-
const fill = shapeFill(palette, COLOR_OVERLOADED, isDark, {
|
|
1116
|
-
...(solid !== undefined && { solid }),
|
|
1117
|
-
});
|
|
1118
|
-
return {
|
|
1119
|
-
fill,
|
|
1120
|
-
stroke: COLOR_OVERLOADED,
|
|
1121
|
-
textFill: contrastText(
|
|
1122
|
-
fill,
|
|
1123
|
-
palette.textOnFillLight,
|
|
1124
|
-
palette.textOnFillDark
|
|
1125
|
-
),
|
|
1126
|
-
};
|
|
1127
|
-
}
|
|
1128
|
-
if (severity === 'warning') {
|
|
1129
|
-
const fill = shapeFill(palette, COLOR_WARNING, isDark, {
|
|
1130
|
-
...(solid !== undefined && { solid }),
|
|
1131
|
-
});
|
|
1132
|
-
return {
|
|
1133
|
-
fill,
|
|
1134
|
-
stroke: COLOR_WARNING,
|
|
1135
|
-
textFill: contrastText(
|
|
1136
|
-
fill,
|
|
1137
|
-
palette.textOnFillLight,
|
|
1138
|
-
palette.textOnFillDark
|
|
1139
|
-
),
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
if (severity === 'healthy') {
|
|
1143
|
-
const fill = shapeFill(palette, COLOR_HEALTHY, isDark, {
|
|
1144
|
-
...(solid !== undefined && { solid }),
|
|
1145
|
-
});
|
|
1146
|
-
return {
|
|
1147
|
-
fill,
|
|
1148
|
-
stroke: COLOR_HEALTHY,
|
|
1149
|
-
textFill: contrastText(
|
|
1150
|
-
fill,
|
|
1151
|
-
palette.textOnFillLight,
|
|
1152
|
-
palette.textOnFillDark
|
|
1153
|
-
),
|
|
1154
|
-
};
|
|
1155
|
-
}
|
|
1156
|
-
// Normal-state node: subtle-neutral 5-10% text tint — intentionally
|
|
1157
|
-
// near-invisible so default nodes recede. Out of scope per TD-2 / F2;
|
|
1158
|
-
// migrating to shapeFill() would 5x its saturation.
|
|
1159
|
-
return {
|
|
1160
|
-
fill: isDark
|
|
1161
|
-
? mix(palette.bg, palette.text, 90)
|
|
1162
|
-
: mix(palette.bg, palette.text, 95),
|
|
1163
|
-
stroke: isDark
|
|
1164
|
-
? mix(palette.text, palette.bg, 60)
|
|
1165
|
-
: mix(palette.text, palette.bg, 40),
|
|
1166
|
-
textFill: palette.text,
|
|
1167
|
-
};
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
function edgeColor(_edge: InfraLayoutEdge, palette: PaletteColors): string {
|
|
1171
|
-
return palette.textMuted;
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
// ============================================================
|
|
1175
|
-
// Render functions
|
|
1176
|
-
// ============================================================
|
|
1177
|
-
|
|
1178
|
-
function renderGroups(
|
|
1179
|
-
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1180
|
-
groups: readonly InfraLayoutGroup[],
|
|
1181
|
-
palette: PaletteColors,
|
|
1182
|
-
_isDark: boolean,
|
|
1183
|
-
sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
|
|
1184
|
-
) {
|
|
1185
|
-
for (const group of groups) {
|
|
1186
|
-
const g = svg
|
|
1187
|
-
.append('g')
|
|
1188
|
-
.attr('class', 'infra-group')
|
|
1189
|
-
.attr('data-line-number', group.lineNumber)
|
|
1190
|
-
.attr('data-node-toggle', group.id)
|
|
1191
|
-
.style('cursor', 'pointer');
|
|
1192
|
-
|
|
1193
|
-
const groupFill = mix(palette.surface, palette.bg, 40);
|
|
1194
|
-
const groupStroke = palette.textMuted;
|
|
1195
|
-
g.append('rect')
|
|
1196
|
-
.attr('x', group.x)
|
|
1197
|
-
.attr('y', group.y)
|
|
1198
|
-
.attr('width', group.width)
|
|
1199
|
-
.attr('height', group.height)
|
|
1200
|
-
.attr('rx', 6)
|
|
1201
|
-
.attr('fill', groupFill)
|
|
1202
|
-
.attr('stroke', groupStroke)
|
|
1203
|
-
.attr('stroke-opacity', 0.35)
|
|
1204
|
-
.attr('stroke-width', 1);
|
|
1205
|
-
|
|
1206
|
-
g.append('text')
|
|
1207
|
-
.attr('x', group.x + group.width / 2)
|
|
1208
|
-
.attr('y', group.y + 16)
|
|
1209
|
-
.attr('text-anchor', 'middle')
|
|
1210
|
-
.attr('font-family', FONT_FAMILY)
|
|
1211
|
-
.attr('font-size', sc.sGroupLabelFontSize)
|
|
1212
|
-
.attr('font-weight', '600')
|
|
1213
|
-
.attr('fill', palette.text)
|
|
1214
|
-
.text(group.label);
|
|
1215
|
-
|
|
1216
|
-
const gi =
|
|
1217
|
-
typeof group.instances === 'number'
|
|
1218
|
-
? group.instances
|
|
1219
|
-
: typeof group.instances === 'string'
|
|
1220
|
-
? parseInt(String(group.instances), 10) || 0
|
|
1221
|
-
: 0;
|
|
1222
|
-
if (gi > 1) {
|
|
1223
|
-
g.append('text')
|
|
1224
|
-
.attr('x', group.x + group.width - 8)
|
|
1225
|
-
.attr('y', group.y + 16)
|
|
1226
|
-
.attr('text-anchor', 'end')
|
|
1227
|
-
.attr('font-family', FONT_FAMILY)
|
|
1228
|
-
.attr('font-size', sc.sMetaFontSize)
|
|
1229
|
-
.attr('fill', palette.textMuted)
|
|
1230
|
-
.text(`${gi}x`);
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
function renderEdgePaths(
|
|
1236
|
-
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1237
|
-
edges: readonly InfraLayoutEdge[],
|
|
1238
|
-
nodes: readonly InfraLayoutNode[],
|
|
1239
|
-
groups: readonly InfraLayoutGroup[],
|
|
1240
|
-
palette: PaletteColors,
|
|
1241
|
-
_isDark: boolean,
|
|
1242
|
-
animate: boolean,
|
|
1243
|
-
direction: 'LR' | 'TB',
|
|
1244
|
-
speedMultiplier: number = 1,
|
|
1245
|
-
sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
|
|
1246
|
-
) {
|
|
1247
|
-
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
1248
|
-
const maxRps = Math.max(...edges.map((e) => e.computedRps), 1);
|
|
1249
|
-
const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
|
|
1250
|
-
|
|
1251
|
-
for (const edge of edges) {
|
|
1252
|
-
if (edge.points.length === 0) continue;
|
|
1253
|
-
|
|
1254
|
-
const targetNode = nodeMap.get(edge.targetId);
|
|
1255
|
-
const sourceNode = nodeMap.get(edge.sourceId);
|
|
1256
|
-
const color = edgeColor(edge, palette);
|
|
1257
|
-
const strokeW = sc.sEdgeStrokeWidth;
|
|
1258
|
-
|
|
1259
|
-
if (!sourceNode || !targetNode) continue;
|
|
1260
|
-
const key = `${edge.sourceId}:${edge.targetId}`;
|
|
1261
|
-
const pts = edgeWaypoints(
|
|
1262
|
-
sourceNode,
|
|
1263
|
-
targetNode,
|
|
1264
|
-
groups,
|
|
1265
|
-
nodes,
|
|
1266
|
-
direction,
|
|
1267
|
-
30,
|
|
1268
|
-
srcPts.get(key),
|
|
1269
|
-
tgtPts.get(key)
|
|
1270
|
-
);
|
|
1271
|
-
const pathD = buildPathD(pts, direction);
|
|
1272
|
-
const edgeG = svg
|
|
1273
|
-
.append('g')
|
|
1274
|
-
.attr('class', 'infra-edge')
|
|
1275
|
-
.attr('data-line-number', edge.lineNumber)
|
|
1276
|
-
// Endpoint node ids for baked-CSS connection-highlight (hover-styles.ts).
|
|
1277
|
-
.attr('data-from', edge.sourceId)
|
|
1278
|
-
.attr('data-to', edge.targetId);
|
|
1279
|
-
|
|
1280
|
-
const edgePath = edgeG
|
|
1281
|
-
.append('path')
|
|
1282
|
-
.attr('d', pathD)
|
|
1283
|
-
.attr('fill', 'none')
|
|
1284
|
-
.attr('stroke', color)
|
|
1285
|
-
.attr('stroke-width', strokeW);
|
|
1286
|
-
if (edge.async) {
|
|
1287
|
-
edgePath.attr('stroke-dasharray', '6 4');
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
if (animate && edge.computedRps > 0) {
|
|
1291
|
-
const baseDur = flowDuration(edge.computedRps, maxRps);
|
|
1292
|
-
const dur = speedMultiplier > 0 ? baseDur / speedMultiplier : baseDur;
|
|
1293
|
-
|
|
1294
|
-
const count = particleCount(edge.computedRps, maxRps);
|
|
1295
|
-
const particleColor = palette.colors.green;
|
|
1296
|
-
|
|
1297
|
-
for (let i = 0; i < count; i++) {
|
|
1298
|
-
const delay = (dur / count) * i;
|
|
1299
|
-
const circle = edgeG
|
|
1300
|
-
.append('circle')
|
|
1301
|
-
.attr('r', sc.sParticleR)
|
|
1302
|
-
.attr('fill', particleColor)
|
|
1303
|
-
.attr('opacity', 0.85);
|
|
1304
|
-
|
|
1305
|
-
circle
|
|
1306
|
-
.append('animateMotion')
|
|
1307
|
-
.attr('dur', `${dur}s`)
|
|
1308
|
-
.attr('repeatCount', 'indefinite')
|
|
1309
|
-
.attr('begin', `${delay}s`)
|
|
1310
|
-
.attr('path', pathD);
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
function renderEdgeLabels(
|
|
1317
|
-
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1318
|
-
edges: readonly InfraLayoutEdge[],
|
|
1319
|
-
nodes: readonly InfraLayoutNode[],
|
|
1320
|
-
groups: readonly InfraLayoutGroup[],
|
|
1321
|
-
palette: PaletteColors,
|
|
1322
|
-
_isDark: boolean,
|
|
1323
|
-
animate: boolean,
|
|
1324
|
-
direction: 'LR' | 'TB',
|
|
1325
|
-
sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
|
|
1326
|
-
) {
|
|
1327
|
-
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
1328
|
-
const { srcPts, tgtPts } = computePortPts(edges, nodeMap, direction);
|
|
1329
|
-
for (const edge of edges) {
|
|
1330
|
-
if (edge.points.length === 0) continue;
|
|
1331
|
-
if (!edge.label) continue;
|
|
1332
|
-
|
|
1333
|
-
const sourceNode = nodeMap.get(edge.sourceId);
|
|
1334
|
-
const targetNode = nodeMap.get(edge.targetId);
|
|
1335
|
-
if (!sourceNode || !targetNode) continue;
|
|
1336
|
-
|
|
1337
|
-
const key = `${edge.sourceId}:${edge.targetId}`;
|
|
1338
|
-
const wps = edgeWaypoints(
|
|
1339
|
-
sourceNode,
|
|
1340
|
-
targetNode,
|
|
1341
|
-
groups,
|
|
1342
|
-
nodes,
|
|
1343
|
-
direction,
|
|
1344
|
-
30,
|
|
1345
|
-
srcPts.get(key),
|
|
1346
|
-
tgtPts.get(key)
|
|
1347
|
-
);
|
|
1348
|
-
const midPt = wps[Math.floor(wps.length / 2)];
|
|
1349
|
-
if (!midPt) continue;
|
|
1350
|
-
const labelText = edge.label;
|
|
1351
|
-
|
|
1352
|
-
const g = svg.append('g').attr('class', animate ? 'infra-edge-label' : '');
|
|
1353
|
-
|
|
1354
|
-
const textWidth = measureText(labelText, EDGE_LABEL_FONT_SIZE) + 8;
|
|
1355
|
-
g.append('rect')
|
|
1356
|
-
.attr('x', midPt.x - textWidth / 2)
|
|
1357
|
-
.attr('y', midPt.y - 8)
|
|
1358
|
-
.attr('width', textWidth)
|
|
1359
|
-
.attr('height', 16)
|
|
1360
|
-
.attr('rx', 3)
|
|
1361
|
-
.attr('fill', palette.bg)
|
|
1362
|
-
.attr('opacity', 0.9);
|
|
1363
|
-
|
|
1364
|
-
g.append('text')
|
|
1365
|
-
.attr('x', midPt.x)
|
|
1366
|
-
.attr('y', midPt.y + 4)
|
|
1367
|
-
.attr('text-anchor', 'middle')
|
|
1368
|
-
.attr('font-family', FONT_FAMILY)
|
|
1369
|
-
.attr('font-size', sc.sEdgeLabelFontSize)
|
|
1370
|
-
.attr('fill', palette.textMuted)
|
|
1371
|
-
.text(labelText);
|
|
1372
|
-
|
|
1373
|
-
if (animate) {
|
|
1374
|
-
const pathD = buildPathD(wps, direction);
|
|
1375
|
-
g.insert('path', ':first-child')
|
|
1376
|
-
.attr('d', pathD)
|
|
1377
|
-
.attr('fill', 'none')
|
|
1378
|
-
.attr('stroke', 'transparent')
|
|
1379
|
-
.attr('stroke-width', 20);
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
/** Returns the resolved tag color for a node's active tag group, or null if not applicable. */
|
|
1385
|
-
function resolveActiveTagStroke(
|
|
1386
|
-
node: InfraLayoutNode,
|
|
1387
|
-
activeGroup: string,
|
|
1388
|
-
tagGroups: readonly InfraTagGroup[],
|
|
1389
|
-
palette: PaletteColors
|
|
1390
|
-
): string | null {
|
|
1391
|
-
const tg = tagGroups.find(
|
|
1392
|
-
(t) => t.name.toLowerCase() === activeGroup.toLowerCase()
|
|
1393
|
-
);
|
|
1394
|
-
if (!tg) return null;
|
|
1395
|
-
const tagKey = tagAttrKey(tg.alias ?? tg.name);
|
|
1396
|
-
const tagVal = node.tags[tagKey];
|
|
1397
|
-
if (!tagVal) return null;
|
|
1398
|
-
const tv = tg.values.find(
|
|
1399
|
-
(v) => v.name.toLowerCase() === tagVal.toLowerCase()
|
|
1400
|
-
);
|
|
1401
|
-
if (!tv?.color) return null;
|
|
1402
|
-
return resolveColor(tv.color, palette);
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
function renderNodes(
|
|
1406
|
-
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1407
|
-
nodes: readonly InfraLayoutNode[],
|
|
1408
|
-
palette: PaletteColors,
|
|
1409
|
-
isDark: boolean,
|
|
1410
|
-
animate: boolean,
|
|
1411
|
-
expandedNodeIds?: Set<string> | null,
|
|
1412
|
-
activeGroup?: string | null,
|
|
1413
|
-
diagramOptions?: Record<string, string>,
|
|
1414
|
-
collapsedNodes?: Set<string> | null,
|
|
1415
|
-
tagGroups?: readonly InfraTagGroup[],
|
|
1416
|
-
fanoutSourceIds?: Set<string>,
|
|
1417
|
-
scaledGroupIds?: Set<string>,
|
|
1418
|
-
sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
|
|
1419
|
-
) {
|
|
1420
|
-
for (const node of nodes) {
|
|
1421
|
-
const slo =
|
|
1422
|
-
!node.isEdge && diagramOptions
|
|
1423
|
-
? resolveNodeSlo(node, diagramOptions)
|
|
1424
|
-
: null;
|
|
1425
|
-
const severity = worstNodeSeverity(node, slo);
|
|
1426
|
-
// Infra INTENTIONALLY ignores `solid-fill` — the renderer relies on subtle
|
|
1427
|
-
// tint gradations to communicate severity tiers (normal / warning /
|
|
1428
|
-
// overloaded), instance counts, RPS load indicators, and the nested
|
|
1429
|
-
// header/body/metrics panel structure. Solid fills collapse all of those
|
|
1430
|
-
// signals into one saturated rectangle, making the diagram unreadable.
|
|
1431
|
-
const nodeColors = nodeColor(node, palette, isDark, severity, false);
|
|
1432
|
-
const textFill = nodeColors.textFill;
|
|
1433
|
-
let { fill, stroke } = nodeColors;
|
|
1434
|
-
|
|
1435
|
-
// When a tag legend is active, override border color with tag color
|
|
1436
|
-
if (activeGroup && tagGroups && !node.isEdge) {
|
|
1437
|
-
const tagStroke = resolveActiveTagStroke(
|
|
1438
|
-
node,
|
|
1439
|
-
activeGroup,
|
|
1440
|
-
tagGroups,
|
|
1441
|
-
palette
|
|
1442
|
-
);
|
|
1443
|
-
if (tagStroke) {
|
|
1444
|
-
stroke = tagStroke;
|
|
1445
|
-
// Canonical 25% tint (matches org/sitemap/c4 tag fills) — was a
|
|
1446
|
-
// muted 6-12% wash that read inconsistently against other charts.
|
|
1447
|
-
fill = shapeFill(palette, tagStroke, isDark);
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
let cls = 'infra-node';
|
|
1451
|
-
if (animate && node.isEdge) {
|
|
1452
|
-
cls += ' infra-node-edge-throb';
|
|
1453
|
-
} else if (animate && !node.isEdge) {
|
|
1454
|
-
if (node.computedCbState === 'open') cls += ' infra-node-cb-open';
|
|
1455
|
-
else if (severity === 'overloaded') cls += ' infra-node-overload';
|
|
1456
|
-
else if (severity === 'warning') cls += ' infra-node-warning';
|
|
1457
|
-
}
|
|
1458
|
-
const g = svg
|
|
1459
|
-
.append('g')
|
|
1460
|
-
.attr('class', cls)
|
|
1461
|
-
.attr('data-line-number', node.lineNumber)
|
|
1462
|
-
.attr('data-infra-node', node.id)
|
|
1463
|
-
.attr('data-node-collapse', node.id)
|
|
1464
|
-
.style('cursor', 'pointer');
|
|
1465
|
-
|
|
1466
|
-
// Collapsed group nodes: toggle attribute
|
|
1467
|
-
if (node.id.startsWith('[')) {
|
|
1468
|
-
g.attr('data-node-toggle', node.id);
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
// Expose tag values for legend hover dimming
|
|
1472
|
-
for (const [tagKey, tagVal] of Object.entries(node.tags)) {
|
|
1473
|
-
g.attr(`data-tag-${tagKey.toLowerCase()}`, tagVal.toLowerCase());
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
// Expose role names for role legend hover dimming
|
|
1477
|
-
if (!node.isEdge) {
|
|
1478
|
-
const roles = inferRoles(node.properties);
|
|
1479
|
-
for (const role of roles) {
|
|
1480
|
-
g.attr(
|
|
1481
|
-
`data-role-${role.name.toLowerCase().replace(/\s+/g, '-')}`,
|
|
1482
|
-
'true'
|
|
1483
|
-
);
|
|
1484
|
-
}
|
|
1485
|
-
if (fanoutSourceIds?.has(node.id)) {
|
|
1486
|
-
g.attr('data-role-fan-out', 'true');
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
const x = node.x - node.width / 2;
|
|
1491
|
-
const y = node.y - node.height / 2;
|
|
1492
|
-
const isCollapsedGroup = node.id.startsWith('[');
|
|
1493
|
-
const strokeWidth =
|
|
1494
|
-
severity !== 'normal' ? sc.sOverloadStrokeWidth : sc.sNodeStrokeWidth;
|
|
1495
|
-
|
|
1496
|
-
g.append('rect')
|
|
1497
|
-
.attr('x', x)
|
|
1498
|
-
.attr('y', y)
|
|
1499
|
-
.attr('width', node.width)
|
|
1500
|
-
.attr('height', node.height)
|
|
1501
|
-
.attr('rx', sc.sNodeBorderRadius)
|
|
1502
|
-
.attr('fill', fill)
|
|
1503
|
-
.attr('stroke', stroke)
|
|
1504
|
-
.attr('stroke-width', strokeWidth);
|
|
1505
|
-
|
|
1506
|
-
const headerCenterY = y + NODE_HEADER_HEIGHT / 2 + sc.sNodeFontSize * 0.35;
|
|
1507
|
-
g.append('text')
|
|
1508
|
-
.attr('x', node.x)
|
|
1509
|
-
.attr('y', headerCenterY)
|
|
1510
|
-
.attr('text-anchor', 'middle')
|
|
1511
|
-
.attr('font-family', FONT_FAMILY)
|
|
1512
|
-
.attr('font-size', sc.sNodeFontSize)
|
|
1513
|
-
.attr('font-weight', '600')
|
|
1514
|
-
.attr('fill', textFill)
|
|
1515
|
-
.text(node.label);
|
|
1516
|
-
|
|
1517
|
-
// --- Key-value rows below header (skipped for collapsed nodes) ---
|
|
1518
|
-
const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
|
|
1519
|
-
if (isNodeCollapsed) {
|
|
1520
|
-
// Collapsed: show a subtle chevron indicator at the bottom of the header
|
|
1521
|
-
const chevronY = y + node.height - 6;
|
|
1522
|
-
g.append('text')
|
|
1523
|
-
.attr('x', node.x)
|
|
1524
|
-
.attr('y', chevronY)
|
|
1525
|
-
.attr('text-anchor', 'middle')
|
|
1526
|
-
.attr('font-family', FONT_FAMILY)
|
|
1527
|
-
.attr('font-size', 8)
|
|
1528
|
-
.attr('fill', textFill)
|
|
1529
|
-
.attr('opacity', 0.6)
|
|
1530
|
-
.text('▶');
|
|
1531
|
-
}
|
|
1532
|
-
if (!isNodeCollapsed) {
|
|
1533
|
-
const expanded = expandedNodeIds?.has(node.id) ?? false;
|
|
1534
|
-
|
|
1535
|
-
// Description subtitle — shown below label only when node is selected
|
|
1536
|
-
const descLines =
|
|
1537
|
-
expanded && node.description && !node.isEdge ? node.description : [];
|
|
1538
|
-
const descH = descLines.length * META_LINE_HEIGHT;
|
|
1539
|
-
for (let di = 0; di < descLines.length; di++) {
|
|
1540
|
-
const rawLine = descLines[di]!;
|
|
1541
|
-
const processed = preprocessDescriptionLine(rawLine);
|
|
1542
|
-
const descTruncated = truncateDesc(processed);
|
|
1543
|
-
const isTruncated = descTruncated !== processed;
|
|
1544
|
-
const textEl = g
|
|
1545
|
-
.append('text')
|
|
1546
|
-
.attr('x', node.x)
|
|
1547
|
-
.attr(
|
|
1548
|
-
'y',
|
|
1549
|
-
y +
|
|
1550
|
-
NODE_HEADER_HEIGHT +
|
|
1551
|
-
di * META_LINE_HEIGHT +
|
|
1552
|
-
META_LINE_HEIGHT / 2 +
|
|
1553
|
-
sc.sMetaFontSize * 0.35
|
|
1554
|
-
)
|
|
1555
|
-
.attr('text-anchor', 'middle')
|
|
1556
|
-
.attr('font-family', FONT_FAMILY)
|
|
1557
|
-
.attr('font-size', sc.sMetaFontSize)
|
|
1558
|
-
.attr('fill', textFill);
|
|
1559
|
-
renderInlineText(textEl, descTruncated, palette, sc.sMetaFontSize);
|
|
1560
|
-
if (isTruncated) textEl.append('title').text(rawLine);
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
// Declared properties only shown when node is selected (expanded)
|
|
1564
|
-
const displayProps =
|
|
1565
|
-
!node.isEdge && expanded
|
|
1566
|
-
? getDisplayProps(node, expanded, diagramOptions)
|
|
1567
|
-
: [];
|
|
1568
|
-
const computedRows = getComputedRows(node, expanded, slo);
|
|
1569
|
-
const hasContent =
|
|
1570
|
-
displayProps.length > 0 ||
|
|
1571
|
-
computedRows.length > 0 ||
|
|
1572
|
-
node.computedRps > 0;
|
|
1573
|
-
|
|
1574
|
-
if (hasContent) {
|
|
1575
|
-
// Separator line between header and body
|
|
1576
|
-
const sepY = y + NODE_HEADER_HEIGHT + descH;
|
|
1577
|
-
g.append('line')
|
|
1578
|
-
.attr('x1', x)
|
|
1579
|
-
.attr('y1', sepY)
|
|
1580
|
-
.attr('x2', x + node.width)
|
|
1581
|
-
.attr('y2', sepY)
|
|
1582
|
-
.attr('stroke', stroke)
|
|
1583
|
-
.attr('stroke-opacity', 0.3)
|
|
1584
|
-
.attr('stroke-width', 1);
|
|
1585
|
-
|
|
1586
|
-
// Build rows in two sections: computed (dynamic) on top, declared (config) below.
|
|
1587
|
-
// A second separator line divides them when both sections have content.
|
|
1588
|
-
const computedSection: NodeRow[] = [];
|
|
1589
|
-
const declaredSection: NodeRow[] = [];
|
|
1590
|
-
|
|
1591
|
-
// Determine effective throughput limit for the RPS row denominator
|
|
1592
|
-
const nodeMaxRps = getNodeNumProp(node, 'max-rps', 0);
|
|
1593
|
-
const nodeRateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
|
|
1594
|
-
const nodeConcurrency = getNodeNumProp(node, 'concurrency', 0);
|
|
1595
|
-
const nodeDurationMs = getNodeNumProp(node, 'duration-ms', 100);
|
|
1596
|
-
const serverlessCap =
|
|
1597
|
-
nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
|
|
1598
|
-
const effectiveCap =
|
|
1599
|
-
serverlessCap > 0
|
|
1600
|
-
? serverlessCap
|
|
1601
|
-
: nodeMaxRps > 0 && nodeRateLimit > 0
|
|
1602
|
-
? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
|
|
1603
|
-
: nodeMaxRps > 0
|
|
1604
|
-
? nodeMaxRps * node.computedInstances
|
|
1605
|
-
: nodeRateLimit > 0
|
|
1606
|
-
? nodeRateLimit
|
|
1607
|
-
: 0;
|
|
1608
|
-
|
|
1609
|
-
// --- Computed section: RPS + computed metrics ---
|
|
1610
|
-
if (node.computedRps > 0) {
|
|
1611
|
-
// Check rate-limit proximity (pre-ratelimit RPS after cache/fw reductions)
|
|
1612
|
-
let rlSeverity: 'overloaded' | 'warning' | 'normal' = 'normal';
|
|
1613
|
-
if (nodeRateLimit > 0 && node.computedRps > 0 && !node.isEdge) {
|
|
1614
|
-
let preRl = node.computedRps;
|
|
1615
|
-
const ch = getNodeNumProp(node, 'cache-hit', 0);
|
|
1616
|
-
if (ch > 0) preRl *= (100 - ch) / 100;
|
|
1617
|
-
const fw = getNodeNumProp(node, 'firewall-block', 0);
|
|
1618
|
-
if (fw > 0) preRl *= (100 - fw) / 100;
|
|
1619
|
-
if (preRl > nodeRateLimit) rlSeverity = 'overloaded';
|
|
1620
|
-
else if (preRl > nodeRateLimit * 0.8) rlSeverity = 'warning';
|
|
1621
|
-
}
|
|
1622
|
-
const rpsSeverity: 'overloaded' | 'warning' | 'normal' =
|
|
1623
|
-
node.overloaded
|
|
1624
|
-
? 'overloaded'
|
|
1625
|
-
: rlSeverity === 'overloaded'
|
|
1626
|
-
? 'overloaded'
|
|
1627
|
-
: node.rateLimited
|
|
1628
|
-
? 'warning'
|
|
1629
|
-
: isWarning(node)
|
|
1630
|
-
? 'warning'
|
|
1631
|
-
: rlSeverity === 'warning'
|
|
1632
|
-
? 'warning'
|
|
1633
|
-
: 'normal';
|
|
1634
|
-
const rpsColor =
|
|
1635
|
-
rpsSeverity === 'overloaded'
|
|
1636
|
-
? COLOR_OVERLOADED
|
|
1637
|
-
: rpsSeverity === 'warning'
|
|
1638
|
-
? COLOR_WARNING
|
|
1639
|
-
: textFill;
|
|
1640
|
-
const rpsInverted = rpsSeverity !== 'normal';
|
|
1641
|
-
const rpsText =
|
|
1642
|
-
effectiveCap > 0 && !node.isEdge
|
|
1643
|
-
? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
|
|
1644
|
-
: formatRpsShort(node.computedRps);
|
|
1645
|
-
computedSection.push({
|
|
1646
|
-
key: 'RPS',
|
|
1647
|
-
value: rpsText,
|
|
1648
|
-
valueFill: rpsColor,
|
|
1649
|
-
fontWeight: '500',
|
|
1650
|
-
inverted: rpsInverted,
|
|
1651
|
-
...(rpsInverted && { invertedBg: rpsColor }),
|
|
1652
|
-
});
|
|
1653
|
-
}
|
|
1654
|
-
for (const cr of computedRows) {
|
|
1655
|
-
computedSection.push({
|
|
1656
|
-
key: cr.key,
|
|
1657
|
-
value: cr.value,
|
|
1658
|
-
valueFill: cr.color ?? textFill,
|
|
1659
|
-
fontWeight: 'normal',
|
|
1660
|
-
...(cr.inverted !== undefined && { inverted: cr.inverted }),
|
|
1661
|
-
...(cr.inverted &&
|
|
1662
|
-
cr.color !== undefined && { invertedBg: cr.color }),
|
|
1663
|
-
});
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
// --- Declared section: user-defined properties (only when node is selected) ---
|
|
1667
|
-
for (const prop of displayProps) {
|
|
1668
|
-
let propColor = textFill;
|
|
1669
|
-
let inverted = false;
|
|
1670
|
-
let invertedBg: string | undefined;
|
|
1671
|
-
if (
|
|
1672
|
-
prop.key === 'ratelimit-rps' &&
|
|
1673
|
-
nodeRateLimit > 0 &&
|
|
1674
|
-
node.computedRps > 0
|
|
1675
|
-
) {
|
|
1676
|
-
let preRl = node.computedRps;
|
|
1677
|
-
const ch = getNodeNumProp(node, 'cache-hit', 0);
|
|
1678
|
-
if (ch > 0) preRl *= (100 - ch) / 100;
|
|
1679
|
-
const fw = getNodeNumProp(node, 'firewall-block', 0);
|
|
1680
|
-
if (fw > 0) preRl *= (100 - fw) / 100;
|
|
1681
|
-
if (preRl > nodeRateLimit) {
|
|
1682
|
-
propColor = COLOR_OVERLOADED;
|
|
1683
|
-
inverted = true;
|
|
1684
|
-
invertedBg = COLOR_OVERLOADED;
|
|
1685
|
-
} else if (preRl > nodeRateLimit * 0.8) {
|
|
1686
|
-
propColor = COLOR_WARNING;
|
|
1687
|
-
inverted = true;
|
|
1688
|
-
invertedBg = COLOR_WARNING;
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1691
|
-
declaredSection.push({
|
|
1692
|
-
key: prop.displayKey,
|
|
1693
|
-
value: prop.value,
|
|
1694
|
-
valueFill: propColor,
|
|
1695
|
-
fontWeight: 'normal',
|
|
1696
|
-
inverted,
|
|
1697
|
-
...(invertedBg !== undefined && { invertedBg }),
|
|
1698
|
-
});
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
const rows = [...computedSection, ...declaredSection];
|
|
1702
|
-
|
|
1703
|
-
// Compute max key width (pixels) so values align vertically.
|
|
1704
|
-
// Keys are drawn as "${key}: ", so measure that exact string.
|
|
1705
|
-
const maxKeyWidth = Math.max(
|
|
1706
|
-
...rows.map((r) => measureText(`${r.key}: `, sc.sMetaFontSize))
|
|
1707
|
-
);
|
|
1708
|
-
const valueX = x + 10 + maxKeyWidth;
|
|
1709
|
-
|
|
1710
|
-
let rowY = sepY + NODE_SEPARATOR_GAP + sc.sMetaFontSize;
|
|
1711
|
-
const needsSectionSep =
|
|
1712
|
-
computedSection.length > 0 && declaredSection.length > 0;
|
|
1713
|
-
let rowIdx = 0;
|
|
1714
|
-
for (const row of rows) {
|
|
1715
|
-
// Draw separator line between computed and declared sections
|
|
1716
|
-
if (needsSectionSep && rowIdx === computedSection.length) {
|
|
1717
|
-
const sepLineY = rowY - sc.sMetaFontSize + 1;
|
|
1718
|
-
g.append('line')
|
|
1719
|
-
.attr('x1', x)
|
|
1720
|
-
.attr('y1', sepLineY)
|
|
1721
|
-
.attr('x2', x + node.width)
|
|
1722
|
-
.attr('y2', sepLineY)
|
|
1723
|
-
.attr('stroke', stroke)
|
|
1724
|
-
.attr('stroke-opacity', 0.3)
|
|
1725
|
-
.attr('stroke-width', 1);
|
|
1726
|
-
rowY += sc.sNodeSeparatorGap;
|
|
1727
|
-
}
|
|
1728
|
-
if (row.inverted && row.invertedBg) {
|
|
1729
|
-
const pillH = sc.sMetaLineHeight - 1;
|
|
1730
|
-
const pillPad = 4;
|
|
1731
|
-
const pillX = x + pillPad;
|
|
1732
|
-
const pillY = rowY - sc.sMetaFontSize + 1;
|
|
1733
|
-
const pillW = node.width - pillPad * 2;
|
|
1734
|
-
g.append('rect')
|
|
1735
|
-
.attr('x', pillX)
|
|
1736
|
-
.attr('y', pillY)
|
|
1737
|
-
.attr('width', pillW)
|
|
1738
|
-
.attr('height', pillH)
|
|
1739
|
-
.attr('rx', 3)
|
|
1740
|
-
.attr('fill', row.invertedBg)
|
|
1741
|
-
.attr('opacity', 0.9);
|
|
1742
|
-
// Inverted text: use lightest available color for max contrast on colored pills
|
|
1743
|
-
const pillTextColor = isDark ? '#ffffff' : palette.text;
|
|
1744
|
-
// Key — same x as normal rows
|
|
1745
|
-
g.append('text')
|
|
1746
|
-
.attr('x', x + 10)
|
|
1747
|
-
.attr('y', rowY)
|
|
1748
|
-
.attr('font-family', FONT_FAMILY)
|
|
1749
|
-
.attr('font-size', sc.sMetaFontSize)
|
|
1750
|
-
.attr('font-weight', '600')
|
|
1751
|
-
.attr('fill', pillTextColor)
|
|
1752
|
-
.text(`${row.key}: `);
|
|
1753
|
-
g.append('text')
|
|
1754
|
-
.attr('x', valueX)
|
|
1755
|
-
.attr('y', rowY)
|
|
1756
|
-
.attr('font-family', FONT_FAMILY)
|
|
1757
|
-
.attr('font-size', sc.sMetaFontSize)
|
|
1758
|
-
.attr('font-weight', '600')
|
|
1759
|
-
.attr('fill', pillTextColor)
|
|
1760
|
-
.text(row.value);
|
|
1761
|
-
} else {
|
|
1762
|
-
g.append('text')
|
|
1763
|
-
.attr('x', x + 10)
|
|
1764
|
-
.attr('y', rowY)
|
|
1765
|
-
.attr('font-family', FONT_FAMILY)
|
|
1766
|
-
.attr('font-size', sc.sMetaFontSize)
|
|
1767
|
-
.attr('fill', textFill)
|
|
1768
|
-
.text(`${row.key}: `);
|
|
1769
|
-
|
|
1770
|
-
g.append('text')
|
|
1771
|
-
.attr('x', valueX)
|
|
1772
|
-
.attr('y', rowY)
|
|
1773
|
-
.attr('font-family', FONT_FAMILY)
|
|
1774
|
-
.attr('font-size', sc.sMetaFontSize)
|
|
1775
|
-
.attr('font-weight', row.fontWeight)
|
|
1776
|
-
.attr('fill', row.valueFill)
|
|
1777
|
-
.text(row.value);
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
rowY += sc.sMetaLineHeight;
|
|
1781
|
-
rowIdx++;
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
// Instance badge — clickable for interactive adjustment (not for edge or serverless nodes)
|
|
1786
|
-
// Serverless nodes show instances in a computed row instead (demand / concurrency).
|
|
1787
|
-
// Nodes inside a scaled group suppress their badge — the group header already shows Nx.
|
|
1788
|
-
const inScaledGroup =
|
|
1789
|
-
node.groupId != null && (scaledGroupIds?.has(node.groupId) ?? false);
|
|
1790
|
-
if (
|
|
1791
|
-
!node.isEdge &&
|
|
1792
|
-
node.computedConcurrentInvocations === 0 &&
|
|
1793
|
-
node.computedInstances > 1 &&
|
|
1794
|
-
!inScaledGroup
|
|
1795
|
-
) {
|
|
1796
|
-
const badgeText = `${node.computedInstances}x`;
|
|
1797
|
-
g.append('text')
|
|
1798
|
-
.attr('x', x + node.width - 6)
|
|
1799
|
-
.attr('y', y + NODE_HEADER_HEIGHT / 2 + sc.sMetaFontSize * 0.35)
|
|
1800
|
-
.attr('text-anchor', 'end')
|
|
1801
|
-
.attr('font-family', FONT_FAMILY)
|
|
1802
|
-
.attr('font-size', sc.sMetaFontSize)
|
|
1803
|
-
.attr('fill', textFill)
|
|
1804
|
-
.attr('data-instance-node', node.id)
|
|
1805
|
-
.style('cursor', 'pointer')
|
|
1806
|
-
.text(badgeText);
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
const showDots = activeGroup?.toLowerCase() === 'capabilities';
|
|
1810
|
-
const roles = showDots && !node.isEdge ? inferRoles(node.properties) : [];
|
|
1811
|
-
if (roles.length > 0) {
|
|
1812
|
-
const dotY = isCollapsedGroup
|
|
1813
|
-
? y + node.height - sc.sCollapseBarHeight - sc.sNodePadBottom / 2
|
|
1814
|
-
: y + node.height - sc.sNodePadBottom / 2;
|
|
1815
|
-
const totalDotsWidth = roles.length * (sc.sRoleDotRadius * 2 + 2) - 2;
|
|
1816
|
-
const startX = node.x - totalDotsWidth / 2 + sc.sRoleDotRadius;
|
|
1817
|
-
for (let i = 0; i < roles.length; i++) {
|
|
1818
|
-
g.append('circle')
|
|
1819
|
-
.attr('cx', startX + i * (sc.sRoleDotRadius * 2 + 2))
|
|
1820
|
-
.attr('cy', dotY)
|
|
1821
|
-
.attr('r', sc.sRoleDotRadius)
|
|
1822
|
-
.attr('fill', roles[i]!.color);
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
if (isCollapsedGroup) {
|
|
1827
|
-
const clipId = `clip-${node.id.replace(/[[\]\s]/g, '')}`;
|
|
1828
|
-
g.append('clipPath')
|
|
1829
|
-
.attr('id', clipId)
|
|
1830
|
-
.append('rect')
|
|
1831
|
-
.attr('x', x)
|
|
1832
|
-
.attr('y', y)
|
|
1833
|
-
.attr('width', node.width)
|
|
1834
|
-
.attr('height', node.height)
|
|
1835
|
-
.attr('rx', sc.sNodeBorderRadius);
|
|
1836
|
-
g.append('rect')
|
|
1837
|
-
.attr('x', x + sc.sCollapseBarInset)
|
|
1838
|
-
.attr('y', y + node.height - sc.sCollapseBarHeight)
|
|
1839
|
-
.attr('width', node.width - sc.sCollapseBarInset * 2)
|
|
1840
|
-
.attr('height', sc.sCollapseBarHeight)
|
|
1841
|
-
.attr('fill', stroke)
|
|
1842
|
-
.attr('clip-path', `url(#${clipId})`)
|
|
1843
|
-
.attr('class', 'infra-collapse-bar');
|
|
1844
|
-
}
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
// ============================================================
|
|
1850
|
-
// Reject Particles — red fading drops for traffic-shedding nodes
|
|
1851
|
-
// ============================================================
|
|
1852
|
-
|
|
1853
|
-
/** Get a numeric property from an InfraLayoutNode. */
|
|
1854
|
-
function getNodeNumProp(
|
|
1855
|
-
node: InfraLayoutNode,
|
|
1856
|
-
key: string,
|
|
1857
|
-
fallback: number
|
|
1858
|
-
): number {
|
|
1859
|
-
const prop = node.properties.find((p) => p.key === key);
|
|
1860
|
-
if (!prop) return fallback;
|
|
1861
|
-
if (typeof prop.value === 'number') return prop.value;
|
|
1862
|
-
const num = parseFloat(String(prop.value));
|
|
1863
|
-
return isNaN(num) ? fallback : num;
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
/**
|
|
1867
|
-
* Compute rejected RPS for a node — traffic that arrives but is dropped/blocked.
|
|
1868
|
-
* This includes: firewall-block, rate-limit excess, and overload excess.
|
|
1869
|
-
* Cache-hit is NOT a reject — the request is served from cache.
|
|
1870
|
-
*/
|
|
1871
|
-
function computeRejectedRps(node: InfraLayoutNode): number {
|
|
1872
|
-
if (node.isEdge || node.computedRps <= 0) return 0;
|
|
1873
|
-
let rejected = 0;
|
|
1874
|
-
const inbound = node.computedRps;
|
|
1875
|
-
|
|
1876
|
-
// Firewall block: N% of inbound is rejected
|
|
1877
|
-
const fwBlock = getNodeNumProp(node, 'firewall-block', 0);
|
|
1878
|
-
if (fwBlock > 0) rejected += inbound * (fwBlock / 100);
|
|
1879
|
-
|
|
1880
|
-
// Rate limit excess (applied after cache/fw reductions)
|
|
1881
|
-
const rateLimit = getNodeNumProp(node, 'ratelimit-rps', 0);
|
|
1882
|
-
if (rateLimit > 0) {
|
|
1883
|
-
let preRateLimitRps = inbound;
|
|
1884
|
-
const cacheHit = getNodeNumProp(node, 'cache-hit', 0);
|
|
1885
|
-
if (cacheHit > 0) preRateLimitRps *= (100 - cacheHit) / 100;
|
|
1886
|
-
if (fwBlock > 0) preRateLimitRps *= (100 - fwBlock) / 100;
|
|
1887
|
-
if (preRateLimitRps > rateLimit) {
|
|
1888
|
-
rejected += preRateLimitRps - rateLimit;
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
// Overload excess
|
|
1893
|
-
if (node.overloaded || node.childHealthState === 'overloaded') {
|
|
1894
|
-
const maxRps = getNodeNumProp(node, 'max-rps', 0);
|
|
1895
|
-
if (maxRps > 0) {
|
|
1896
|
-
const capacity = maxRps * node.computedInstances;
|
|
1897
|
-
if (inbound > capacity) {
|
|
1898
|
-
rejected += inbound - capacity;
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
// For collapsed groups: childHealthState signals child overload even when
|
|
1902
|
-
// the virtual node's aggregated capacity math doesn't trigger (due to
|
|
1903
|
-
// group instances being baked into both max-rps and computedInstances).
|
|
1904
|
-
// Ensure we always produce a reject signal.
|
|
1905
|
-
if (rejected === 0) {
|
|
1906
|
-
rejected = inbound * 0.1;
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
return rejected;
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
function renderRejectParticles(
|
|
1914
|
-
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1915
|
-
nodes: readonly InfraLayoutNode[],
|
|
1916
|
-
speedMultiplier: number = 1,
|
|
1917
|
-
sc: ScaledInfraConstants = buildScaledConstants(ScaleContext.identity())
|
|
1918
|
-
) {
|
|
1919
|
-
// Compute max rejected RPS across all nodes for scaling
|
|
1920
|
-
const rejectMap: { node: InfraLayoutNode; rejected: number }[] = [];
|
|
1921
|
-
for (const node of nodes) {
|
|
1922
|
-
const rejected = computeRejectedRps(node);
|
|
1923
|
-
if (rejected > 0) rejectMap.push({ node, rejected });
|
|
1924
|
-
}
|
|
1925
|
-
if (rejectMap.length === 0) return;
|
|
1926
|
-
|
|
1927
|
-
const maxRejected = Math.max(...rejectMap.map((r) => r.rejected));
|
|
1928
|
-
|
|
1929
|
-
for (const { node, rejected } of rejectMap) {
|
|
1930
|
-
const t = Math.min(rejected / maxRejected, 1);
|
|
1931
|
-
const count = Math.round(
|
|
1932
|
-
REJECT_COUNT_MIN + t * (REJECT_COUNT_MAX - REJECT_COUNT_MIN)
|
|
1933
|
-
);
|
|
1934
|
-
const baseDur =
|
|
1935
|
-
REJECT_DURATION_MAX - t * (REJECT_DURATION_MAX - REJECT_DURATION_MIN);
|
|
1936
|
-
const dur = speedMultiplier > 0 ? baseDur / speedMultiplier : baseDur;
|
|
1937
|
-
|
|
1938
|
-
const nodeBottom = node.y + node.height / 2;
|
|
1939
|
-
|
|
1940
|
-
for (let i = 0; i < count; i++) {
|
|
1941
|
-
const delay = (dur / count) * i;
|
|
1942
|
-
// Spread particles horizontally around the node center
|
|
1943
|
-
const spread = node.width * 0.3;
|
|
1944
|
-
const startX =
|
|
1945
|
-
node.x + (count > 1 ? -spread / 2 + spread * (i / (count - 1)) : 0);
|
|
1946
|
-
const startY = nodeBottom;
|
|
1947
|
-
const endY = nodeBottom + sc.sRejectDropDistance;
|
|
1948
|
-
|
|
1949
|
-
const rejectColor =
|
|
1950
|
-
node.overloaded || node.childHealthState === 'overloaded'
|
|
1951
|
-
? COLOR_OVERLOADED
|
|
1952
|
-
: COLOR_WARNING;
|
|
1953
|
-
const circle = svg
|
|
1954
|
-
.append('circle')
|
|
1955
|
-
.attr('r', sc.sRejectParticleR)
|
|
1956
|
-
.attr('fill', rejectColor)
|
|
1957
|
-
.attr('opacity', 0);
|
|
1958
|
-
|
|
1959
|
-
// Drop straight down from node bottom
|
|
1960
|
-
circle
|
|
1961
|
-
.append('animate')
|
|
1962
|
-
.attr('attributeName', 'cy')
|
|
1963
|
-
.attr('from', startY)
|
|
1964
|
-
.attr('to', endY)
|
|
1965
|
-
.attr('dur', `${dur}s`)
|
|
1966
|
-
.attr('repeatCount', 'indefinite')
|
|
1967
|
-
.attr('begin', `${delay}s`);
|
|
1968
|
-
|
|
1969
|
-
circle
|
|
1970
|
-
.append('animate')
|
|
1971
|
-
.attr('attributeName', 'cx')
|
|
1972
|
-
.attr('from', startX)
|
|
1973
|
-
.attr('to', startX)
|
|
1974
|
-
.attr('dur', `${dur}s`)
|
|
1975
|
-
.attr('repeatCount', 'indefinite')
|
|
1976
|
-
.attr('begin', `${delay}s`);
|
|
1977
|
-
|
|
1978
|
-
// Fade: appear quickly, then fade out
|
|
1979
|
-
circle
|
|
1980
|
-
.append('animate')
|
|
1981
|
-
.attr('attributeName', 'opacity')
|
|
1982
|
-
.attr('values', '0;0.8;0.6;0')
|
|
1983
|
-
.attr('keyTimes', '0;0.1;0.5;1')
|
|
1984
|
-
.attr('dur', `${dur}s`)
|
|
1985
|
-
.attr('repeatCount', 'indefinite')
|
|
1986
|
-
.attr('begin', `${delay}s`);
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
// ============================================================
|
|
1992
|
-
// Legend — pill/capsule groups (matching org chart style)
|
|
1993
|
-
// ============================================================
|
|
1994
|
-
|
|
1995
|
-
interface InfraLegendEntry {
|
|
1996
|
-
value: string;
|
|
1997
|
-
color: string;
|
|
1998
|
-
/** For role: kebab-case role name. For tag: lowercase tag value. */
|
|
1999
|
-
key: string;
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
export interface InfraLegendGroup {
|
|
2003
|
-
name: string;
|
|
2004
|
-
type: 'role' | 'tag';
|
|
2005
|
-
/** For tag groups: the key used in data-tag-* attributes (alias or name). */
|
|
2006
|
-
tagKey?: string;
|
|
2007
|
-
entries: InfraLegendEntry[];
|
|
2008
|
-
width: number;
|
|
2009
|
-
minifiedWidth: number;
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
/** Build legend groups from roles + tags. */
|
|
2013
|
-
export function computeInfraLegendGroups(
|
|
2014
|
-
nodes: readonly InfraLayoutNode[],
|
|
2015
|
-
tagGroups: readonly InfraTagGroup[],
|
|
2016
|
-
palette: PaletteColors,
|
|
2017
|
-
edges?: readonly InfraLayoutEdge[]
|
|
2018
|
-
): InfraLegendGroup[] {
|
|
2019
|
-
const groups: InfraLegendGroup[] = [];
|
|
2020
|
-
|
|
2021
|
-
// Capabilities group (from inferred roles + fanout edges)
|
|
2022
|
-
const roles = collectDiagramRoles(
|
|
2023
|
-
nodes.filter((n) => !n.isEdge).map((n) => n.properties)
|
|
2024
|
-
);
|
|
2025
|
-
if (edges && collectFanoutSourceIds([...edges]).size > 0) {
|
|
2026
|
-
roles.push(FANOUT_ROLE);
|
|
2027
|
-
}
|
|
2028
|
-
if (roles.length > 0) {
|
|
2029
|
-
const entries = roles.map((r) => ({
|
|
2030
|
-
value: r.name,
|
|
2031
|
-
color: r.color,
|
|
2032
|
-
key: r.name.toLowerCase().replace(/\s+/g, '-'),
|
|
2033
|
-
}));
|
|
2034
|
-
const pillWidth =
|
|
2035
|
-
measureLegendText('Capabilities', LEGEND_PILL_FONT_SIZE) +
|
|
2036
|
-
LEGEND_PILL_PAD;
|
|
2037
|
-
let entriesWidth = 0;
|
|
2038
|
-
for (const e of entries) {
|
|
2039
|
-
entriesWidth +=
|
|
2040
|
-
LEGEND_DOT_R * 2 +
|
|
2041
|
-
LEGEND_ENTRY_DOT_GAP +
|
|
2042
|
-
measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
2043
|
-
LEGEND_ENTRY_TRAIL;
|
|
2044
|
-
}
|
|
2045
|
-
groups.push({
|
|
2046
|
-
name: 'Capabilities',
|
|
2047
|
-
type: 'role',
|
|
2048
|
-
entries,
|
|
2049
|
-
width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
|
|
2050
|
-
minifiedWidth: pillWidth,
|
|
2051
|
-
});
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
// Tag groups
|
|
2055
|
-
for (const tg of tagGroups) {
|
|
2056
|
-
const entries: InfraLegendEntry[] = [];
|
|
2057
|
-
for (const tv of tg.values) {
|
|
2058
|
-
if (tv.color) {
|
|
2059
|
-
entries.push({
|
|
2060
|
-
value: tv.name,
|
|
2061
|
-
color: resolveColor(tv.color, palette) ?? tv.color,
|
|
2062
|
-
key: tv.name.toLowerCase(),
|
|
2063
|
-
});
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
if (entries.length === 0) continue;
|
|
2067
|
-
const pillWidth =
|
|
2068
|
-
measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
2069
|
-
let entriesWidth = 0;
|
|
2070
|
-
for (const e of entries) {
|
|
2071
|
-
entriesWidth +=
|
|
2072
|
-
LEGEND_DOT_R * 2 +
|
|
2073
|
-
LEGEND_ENTRY_DOT_GAP +
|
|
2074
|
-
measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
2075
|
-
LEGEND_ENTRY_TRAIL;
|
|
2076
|
-
}
|
|
2077
|
-
groups.push({
|
|
2078
|
-
name: tg.name,
|
|
2079
|
-
type: 'tag',
|
|
2080
|
-
tagKey: tagAttrKey(tg.alias ?? tg.name),
|
|
2081
|
-
entries,
|
|
2082
|
-
width: LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth,
|
|
2083
|
-
minifiedWidth: pillWidth,
|
|
2084
|
-
});
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
return groups;
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
function renderLegend(
|
|
2091
|
-
rootSvg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
|
|
2092
|
-
legendGroups: InfraLegendGroup[],
|
|
2093
|
-
totalWidth: number,
|
|
2094
|
-
legendY: number,
|
|
2095
|
-
palette: PaletteColors,
|
|
2096
|
-
isDark: boolean,
|
|
2097
|
-
activeGroup: string | null,
|
|
2098
|
-
playback?: InfraPlaybackState,
|
|
2099
|
-
exportMode = false,
|
|
2100
|
-
controlsHost?: 'app' | 'inline'
|
|
2101
|
-
) {
|
|
2102
|
-
if (legendGroups.length === 0 && !playback) return;
|
|
2103
|
-
// App-hosted playback: the play/pause + speed UI lives in the app overlay
|
|
2104
|
-
// strip, so suppress the in-SVG Playback pill and emit the controls anchor.
|
|
2105
|
-
const appHostedPlayback = controlsHost === 'app' && !!playback;
|
|
2106
|
-
|
|
2107
|
-
const legendG = rootSvg
|
|
2108
|
-
.append('g')
|
|
2109
|
-
.attr('transform', `translate(0, ${legendY})`);
|
|
2110
|
-
|
|
2111
|
-
if (activeGroup) {
|
|
2112
|
-
legendG.attr('data-legend-active', tagAttrKey(activeGroup));
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
// Build all groups including Playback as a regular group for consistent rendering
|
|
2116
|
-
const allGroups = legendGroups.map((g) => ({
|
|
2117
|
-
name: g.name,
|
|
2118
|
-
entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
|
|
2119
|
-
}));
|
|
2120
|
-
// Add Playback as a group with empty entries (collapsed pill) or dummy entries
|
|
2121
|
-
// (expanded) — unless the app hosts it, in which case it's suppressed.
|
|
2122
|
-
if (playback && !appHostedPlayback) {
|
|
2123
|
-
allGroups.push({ name: 'Playback', entries: [] });
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
renderIntegratedLegend(legendG, {
|
|
2127
|
-
groups: allGroups,
|
|
2128
|
-
activeGroup,
|
|
2129
|
-
mode: exportMode ? 'export' : 'preview',
|
|
2130
|
-
showEmptyGroups: true,
|
|
2131
|
-
...(appHostedPlayback && {
|
|
2132
|
-
controlsHost: 'app' as const,
|
|
2133
|
-
controlsGroup: {
|
|
2134
|
-
toggles: [
|
|
2135
|
-
{
|
|
2136
|
-
id: 'playback',
|
|
2137
|
-
type: 'toggle' as const,
|
|
2138
|
-
label: 'Playback',
|
|
2139
|
-
active: true,
|
|
2140
|
-
onToggle: () => {},
|
|
2141
|
-
},
|
|
2142
|
-
],
|
|
2143
|
-
},
|
|
2144
|
-
}),
|
|
2145
|
-
palette,
|
|
2146
|
-
isDark,
|
|
2147
|
-
width: totalWidth,
|
|
2148
|
-
});
|
|
2149
|
-
|
|
2150
|
-
// Add infra-specific classes and data attributes for app interactivity
|
|
2151
|
-
legendG.selectAll('[data-legend-group]').classed('infra-legend-group', true);
|
|
2152
|
-
for (const group of legendGroups) {
|
|
2153
|
-
const groupKey = tagAttrKey(group.name);
|
|
2154
|
-
for (const entry of group.entries) {
|
|
2155
|
-
const entryEl = legendG.select(
|
|
2156
|
-
`[data-legend-group="${groupKey}"] [data-legend-entry="${entry.value.toLowerCase()}"]`
|
|
2157
|
-
);
|
|
2158
|
-
if (!entryEl.empty()) {
|
|
2159
|
-
entryEl
|
|
2160
|
-
.attr('data-legend-entry', entry.key.toLowerCase())
|
|
2161
|
-
.attr('data-legend-color', entry.color)
|
|
2162
|
-
.attr('data-legend-type', group.type)
|
|
2163
|
-
.attr(
|
|
2164
|
-
'data-legend-tag-group',
|
|
2165
|
-
group.type === 'tag' ? (group.tagKey ?? '') : null
|
|
2166
|
-
);
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2171
|
-
// Mark the Playback pill with infra-playback-pill class for app interactivity
|
|
2172
|
-
const playbackEl = legendG.select('[data-legend-group="playback"]');
|
|
2173
|
-
if (!playbackEl.empty()) {
|
|
2174
|
-
playbackEl.classed('infra-playback-pill', true);
|
|
2175
|
-
}
|
|
2176
|
-
|
|
2177
|
-
// Inject speed badges into the Playback pill when expanded
|
|
2178
|
-
// Speed badges are injected into the Playback pill below
|
|
2179
|
-
|
|
2180
|
-
// Inject speed badges into the Playback pill when expanded
|
|
2181
|
-
if (playback && playback.expanded && !playbackEl.empty()) {
|
|
2182
|
-
const pillWidth =
|
|
2183
|
-
measureLegendText('Playback', LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
2184
|
-
|
|
2185
|
-
let entryX = pillWidth + 8;
|
|
2186
|
-
const entryY = LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1;
|
|
2187
|
-
|
|
2188
|
-
const ppLabel = playback.paused ? '▶' : '⏸';
|
|
2189
|
-
playbackEl
|
|
2190
|
-
.append('text')
|
|
2191
|
-
.attr('x', entryX)
|
|
2192
|
-
.attr('y', entryY)
|
|
2193
|
-
.attr('font-family', FONT_FAMILY)
|
|
2194
|
-
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
2195
|
-
.attr('fill', palette.textMuted)
|
|
2196
|
-
.attr('data-playback-action', 'toggle-pause')
|
|
2197
|
-
.style('cursor', 'pointer')
|
|
2198
|
-
.text(ppLabel);
|
|
2199
|
-
entryX += LEGEND_PILL_FONT_SIZE * 0.8 + 6;
|
|
2200
|
-
|
|
2201
|
-
for (const s of playback.speedOptions) {
|
|
2202
|
-
const label = `${s}x`;
|
|
2203
|
-
const isSpeedActive = playback.speed === s;
|
|
2204
|
-
const slotW =
|
|
2205
|
-
measureLegendText(label, LEGEND_ENTRY_FONT_SIZE) +
|
|
2206
|
-
SPEED_BADGE_H_PAD * 2;
|
|
2207
|
-
const badgeH = LEGEND_ENTRY_FONT_SIZE + SPEED_BADGE_V_PAD * 2;
|
|
2208
|
-
const badgeY = (LEGEND_HEIGHT - badgeH) / 2;
|
|
2209
|
-
|
|
2210
|
-
const speedG = playbackEl
|
|
2211
|
-
.append('g')
|
|
2212
|
-
.attr('data-playback-action', 'set-speed')
|
|
2213
|
-
.attr('data-playback-value', String(s))
|
|
2214
|
-
.style('cursor', 'pointer');
|
|
2215
|
-
|
|
2216
|
-
speedG
|
|
2217
|
-
.append('rect')
|
|
2218
|
-
.attr('x', entryX)
|
|
2219
|
-
.attr('y', badgeY)
|
|
2220
|
-
.attr('width', slotW)
|
|
2221
|
-
.attr('height', badgeH)
|
|
2222
|
-
.attr('rx', badgeH / 2)
|
|
2223
|
-
.attr('fill', isSpeedActive ? palette.primary : 'transparent');
|
|
2224
|
-
|
|
2225
|
-
speedG
|
|
2226
|
-
.append('text')
|
|
2227
|
-
.attr('x', entryX + slotW / 2)
|
|
2228
|
-
.attr('y', entryY)
|
|
2229
|
-
.attr('font-family', FONT_FAMILY)
|
|
2230
|
-
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
2231
|
-
.attr('font-weight', isSpeedActive ? '600' : '400')
|
|
2232
|
-
.attr('fill', isSpeedActive ? palette.bg : palette.textMuted)
|
|
2233
|
-
.attr('text-anchor', 'middle')
|
|
2234
|
-
.text(label);
|
|
2235
|
-
entryX += slotW + SPEED_BADGE_GAP;
|
|
2236
|
-
}
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
|
|
2240
|
-
// ============================================================
|
|
2241
|
-
// Main render
|
|
2242
|
-
// ============================================================
|
|
2243
|
-
|
|
2244
|
-
export interface InfraPlaybackState {
|
|
2245
|
-
expanded: boolean;
|
|
2246
|
-
paused: boolean;
|
|
2247
|
-
speed: number;
|
|
2248
|
-
speedOptions: readonly number[];
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
export function renderInfra(
|
|
2252
|
-
container: HTMLDivElement,
|
|
2253
|
-
layout: InfraLayoutResult,
|
|
2254
|
-
palette: PaletteColors,
|
|
2255
|
-
isDark: boolean,
|
|
2256
|
-
title: string | null,
|
|
2257
|
-
titleLineNumber: number | null,
|
|
2258
|
-
tagGroups?: readonly InfraTagGroup[],
|
|
2259
|
-
activeGroup?: string | null,
|
|
2260
|
-
animate?: boolean,
|
|
2261
|
-
playback?: InfraPlaybackState | null,
|
|
2262
|
-
expandedNodeIds?: Set<string> | null,
|
|
2263
|
-
exportMode?: boolean,
|
|
2264
|
-
collapsedNodes?: Set<string> | null,
|
|
2265
|
-
/** When 'app', the playback pill is suppressed and a controls row + anchor are
|
|
2266
|
-
* reserved for the app overlay strip (play/pause + speed live there). */
|
|
2267
|
-
controlsHost?: 'app' | 'inline'
|
|
2268
|
-
) {
|
|
2269
|
-
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
2270
|
-
const appHostedPlayback = controlsHost === 'app' && !!playback;
|
|
2271
|
-
|
|
2272
|
-
const ctx = ScaleContext.identity();
|
|
2273
|
-
const sc = buildScaledConstants(ctx);
|
|
2274
|
-
|
|
2275
|
-
const legendGroups = computeInfraLegendGroups(
|
|
2276
|
-
layout.nodes,
|
|
2277
|
-
tagGroups ?? [],
|
|
2278
|
-
palette,
|
|
2279
|
-
layout.edges
|
|
2280
|
-
);
|
|
2281
|
-
// App-hosted: the playback pill moves to the app overlay, so a playback-only
|
|
2282
|
-
// legend (no tag groups) has nothing left to render.
|
|
2283
|
-
const hasLegend =
|
|
2284
|
-
legendGroups.length > 0 || (!!playback && !appHostedPlayback);
|
|
2285
|
-
const fixedLegend = !exportMode && hasLegend;
|
|
2286
|
-
const legendDynamicH = hasLegend
|
|
2287
|
-
? getMaxLegendReservedHeight(
|
|
2288
|
-
{
|
|
2289
|
-
groups: legendGroups,
|
|
2290
|
-
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
2291
|
-
mode: exportMode ? 'export' : 'preview',
|
|
2292
|
-
showEmptyGroups: true,
|
|
2293
|
-
},
|
|
2294
|
-
container.clientWidth || layout.width
|
|
2295
|
-
)
|
|
2296
|
-
: LEGEND_HEIGHT;
|
|
2297
|
-
const legendOffset = hasLegend && !fixedLegend ? legendDynamicH : 0;
|
|
2298
|
-
|
|
2299
|
-
const titleOffset = title ? TITLE_OFFSET : 0;
|
|
2300
|
-
const totalWidth = layout.width;
|
|
2301
|
-
const totalHeight = layout.height + titleOffset + legendOffset;
|
|
2302
|
-
|
|
2303
|
-
const shouldAnimate = animate !== false;
|
|
2304
|
-
|
|
2305
|
-
const fixedTitleH = fixedLegend && title ? TITLE_OFFSET : 0;
|
|
2306
|
-
const diagramViewHeight = fixedLegend
|
|
2307
|
-
? layout.height + (title && !fixedTitleH ? titleOffset : 0) + legendOffset
|
|
2308
|
-
: totalHeight;
|
|
2309
|
-
|
|
2310
|
-
if (fixedTitleH) {
|
|
2311
|
-
const titleContainerW = container.clientWidth || totalWidth;
|
|
2312
|
-
const titleSvg = d3Selection
|
|
2313
|
-
.select(container)
|
|
2314
|
-
.append('svg')
|
|
2315
|
-
.attr('class', 'infra-title-fixed')
|
|
2316
|
-
.attr('width', '100%')
|
|
2317
|
-
.attr('height', fixedTitleH)
|
|
2318
|
-
.attr('viewBox', `0 0 ${titleContainerW} ${fixedTitleH}`)
|
|
2319
|
-
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
2320
|
-
.style('display', 'block');
|
|
2321
|
-
titleSvg
|
|
2322
|
-
.append('text')
|
|
2323
|
-
.attr('class', 'chart-title')
|
|
2324
|
-
.attr('x', titleContainerW / 2)
|
|
2325
|
-
.attr('y', sc.sTitleY)
|
|
2326
|
-
.attr('text-anchor', 'middle')
|
|
2327
|
-
.attr('font-family', FONT_FAMILY)
|
|
2328
|
-
.attr('font-size', sc.sTitleFontSize)
|
|
2329
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
2330
|
-
.attr('fill', palette.text)
|
|
2331
|
-
.attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
|
|
2332
|
-
.text(title!);
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
const fixedOverheadH =
|
|
2336
|
-
(fixedLegend ? legendDynamicH + sc.sLegendFixedGap : 0) + fixedTitleH;
|
|
2337
|
-
const rootSvg = d3Selection
|
|
2338
|
-
.select(container)
|
|
2339
|
-
.append('svg')
|
|
2340
|
-
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
2341
|
-
.attr('width', '100%')
|
|
2342
|
-
.attr(
|
|
2343
|
-
'height',
|
|
2344
|
-
fixedOverheadH > 0 ? `calc(100% - ${fixedOverheadH}px)` : '100%'
|
|
2345
|
-
)
|
|
2346
|
-
.attr('viewBox', `0 0 ${totalWidth} ${diagramViewHeight}`)
|
|
2347
|
-
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
2348
|
-
|
|
2349
|
-
if (ctx.isBelowFloor) {
|
|
2350
|
-
rootSvg.attr('width', '100%');
|
|
2351
|
-
}
|
|
2352
|
-
|
|
2353
|
-
if (shouldAnimate) {
|
|
2354
|
-
rootSvg.append('style').text(`
|
|
2355
|
-
@keyframes infra-pulse-warning {
|
|
2356
|
-
0%, 100% { opacity: 1; }
|
|
2357
|
-
50% { opacity: 0.7; }
|
|
2358
|
-
}
|
|
2359
|
-
@keyframes infra-pulse-overload {
|
|
2360
|
-
0%, 100% { stroke-width: ${sc.sOverloadStrokeWidth}; }
|
|
2361
|
-
50% { stroke-width: ${sc.sOverloadStrokeWidth + 2}; }
|
|
2362
|
-
}
|
|
2363
|
-
@keyframes infra-pulse-cb {
|
|
2364
|
-
0%, 49% { stroke-dasharray: none; }
|
|
2365
|
-
50%, 100% { stroke-dasharray: 6 4; }
|
|
2366
|
-
}
|
|
2367
|
-
.infra-node-warning > rect:first-of-type {
|
|
2368
|
-
animation: infra-pulse-warning ${NODE_PULSE_SPEED}s ease-in-out infinite;
|
|
2369
|
-
}
|
|
2370
|
-
.infra-node-overload > rect:first-of-type {
|
|
2371
|
-
animation: infra-pulse-overload ${NODE_PULSE_OVERLOAD}s ease-in-out infinite;
|
|
2372
|
-
}
|
|
2373
|
-
.infra-node-cb-open > rect:first-of-type {
|
|
2374
|
-
stroke-dasharray: 6 4;
|
|
2375
|
-
animation: infra-pulse-cb 1s step-end infinite;
|
|
2376
|
-
}
|
|
2377
|
-
@keyframes infra-edge-throb {
|
|
2378
|
-
0%, 100% { stroke-width: ${sc.sEdgeStrokeWidth}; }
|
|
2379
|
-
50% { stroke-width: ${sc.sEdgeStrokeWidth * 2}; }
|
|
2380
|
-
}
|
|
2381
|
-
.infra-node-edge-throb > rect:first-of-type {
|
|
2382
|
-
animation: infra-edge-throb 2s ease-in-out infinite;
|
|
2383
|
-
cursor: pointer;
|
|
2384
|
-
}
|
|
2385
|
-
.infra-edge-label {
|
|
2386
|
-
opacity: 0;
|
|
2387
|
-
transition: opacity 0.15s ease;
|
|
2388
|
-
pointer-events: all;
|
|
2389
|
-
}
|
|
2390
|
-
.infra-edge-label:hover {
|
|
2391
|
-
opacity: 1;
|
|
2392
|
-
}
|
|
2393
|
-
`);
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
const contentTitleOffset = fixedTitleH ? 0 : titleOffset;
|
|
2397
|
-
const svg = rootSvg
|
|
2398
|
-
.append('g')
|
|
2399
|
-
.attr('transform', `translate(0, ${contentTitleOffset + legendOffset})`);
|
|
2400
|
-
|
|
2401
|
-
if (title && !fixedTitleH) {
|
|
2402
|
-
rootSvg
|
|
2403
|
-
.append('text')
|
|
2404
|
-
.attr('class', 'chart-title')
|
|
2405
|
-
.attr('x', totalWidth / 2)
|
|
2406
|
-
.attr('y', sc.sTitleY)
|
|
2407
|
-
.attr('text-anchor', 'middle')
|
|
2408
|
-
.attr('font-family', FONT_FAMILY)
|
|
2409
|
-
.attr('font-size', sc.sTitleFontSize)
|
|
2410
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
2411
|
-
.attr('fill', palette.text)
|
|
2412
|
-
.attr('data-line-number', titleLineNumber != null ? titleLineNumber : '')
|
|
2413
|
-
.text(title);
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
|
-
renderGroups(svg, layout.groups, palette, isDark, sc);
|
|
2417
|
-
const speedMultiplier = playback?.speed ?? 1;
|
|
2418
|
-
renderEdgePaths(
|
|
2419
|
-
svg,
|
|
2420
|
-
layout.edges,
|
|
2421
|
-
layout.nodes,
|
|
2422
|
-
layout.groups,
|
|
2423
|
-
palette,
|
|
2424
|
-
isDark,
|
|
2425
|
-
shouldAnimate,
|
|
2426
|
-
layout.direction,
|
|
2427
|
-
speedMultiplier,
|
|
2428
|
-
sc
|
|
2429
|
-
);
|
|
2430
|
-
const fanoutSourceIds = collectFanoutSourceIds([...layout.edges]);
|
|
2431
|
-
const scaledGroupIds = new Set<string>(
|
|
2432
|
-
layout.groups
|
|
2433
|
-
.filter((g) => {
|
|
2434
|
-
const gi =
|
|
2435
|
-
typeof g.instances === 'number'
|
|
2436
|
-
? g.instances
|
|
2437
|
-
: typeof g.instances === 'string'
|
|
2438
|
-
? parseInt(String(g.instances), 10) || 0
|
|
2439
|
-
: 0;
|
|
2440
|
-
return gi > 1;
|
|
2441
|
-
})
|
|
2442
|
-
.map((g) => g.id)
|
|
2443
|
-
);
|
|
2444
|
-
renderNodes(
|
|
2445
|
-
svg,
|
|
2446
|
-
layout.nodes,
|
|
2447
|
-
palette,
|
|
2448
|
-
isDark,
|
|
2449
|
-
shouldAnimate,
|
|
2450
|
-
expandedNodeIds,
|
|
2451
|
-
activeGroup,
|
|
2452
|
-
layout.options,
|
|
2453
|
-
collapsedNodes,
|
|
2454
|
-
tagGroups ?? [],
|
|
2455
|
-
fanoutSourceIds,
|
|
2456
|
-
scaledGroupIds,
|
|
2457
|
-
sc
|
|
2458
|
-
);
|
|
2459
|
-
if (shouldAnimate) {
|
|
2460
|
-
renderRejectParticles(svg, layout.nodes, speedMultiplier, sc);
|
|
2461
|
-
}
|
|
2462
|
-
renderEdgeLabels(
|
|
2463
|
-
svg,
|
|
2464
|
-
layout.edges,
|
|
2465
|
-
layout.nodes,
|
|
2466
|
-
layout.groups,
|
|
2467
|
-
palette,
|
|
2468
|
-
isDark,
|
|
2469
|
-
shouldAnimate,
|
|
2470
|
-
layout.direction,
|
|
2471
|
-
sc
|
|
2472
|
-
);
|
|
2473
|
-
|
|
2474
|
-
if (hasLegend) {
|
|
2475
|
-
if (fixedLegend) {
|
|
2476
|
-
const legendContainerW = container.clientWidth || totalWidth;
|
|
2477
|
-
const legendSvg = d3Selection
|
|
2478
|
-
.select(container)
|
|
2479
|
-
.insert('svg', 'svg:last-of-type')
|
|
2480
|
-
.attr('class', 'infra-legend-fixed')
|
|
2481
|
-
.attr('width', '100%')
|
|
2482
|
-
.attr('height', legendDynamicH + sc.sLegendFixedGap)
|
|
2483
|
-
.attr(
|
|
2484
|
-
'viewBox',
|
|
2485
|
-
`0 0 ${legendContainerW} ${legendDynamicH + sc.sLegendFixedGap}`
|
|
2486
|
-
)
|
|
2487
|
-
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
2488
|
-
.style('display', 'block')
|
|
2489
|
-
.style('pointer-events', 'none');
|
|
2490
|
-
renderLegend(
|
|
2491
|
-
legendSvg,
|
|
2492
|
-
legendGroups,
|
|
2493
|
-
legendContainerW,
|
|
2494
|
-
sc.sLegendFixedGap / 2,
|
|
2495
|
-
palette,
|
|
2496
|
-
isDark,
|
|
2497
|
-
activeGroup ?? null,
|
|
2498
|
-
playback ?? undefined,
|
|
2499
|
-
exportMode,
|
|
2500
|
-
controlsHost
|
|
2501
|
-
);
|
|
2502
|
-
// Re-enable pointer events on interactive legend elements
|
|
2503
|
-
legendSvg
|
|
2504
|
-
.selectAll('.infra-legend-group')
|
|
2505
|
-
.style('pointer-events', 'auto');
|
|
2506
|
-
} else {
|
|
2507
|
-
// Export mode: render legend at top (below title)
|
|
2508
|
-
renderLegend(
|
|
2509
|
-
rootSvg,
|
|
2510
|
-
legendGroups,
|
|
2511
|
-
totalWidth,
|
|
2512
|
-
titleOffset,
|
|
2513
|
-
palette,
|
|
2514
|
-
isDark,
|
|
2515
|
-
activeGroup ?? null,
|
|
2516
|
-
playback ?? undefined,
|
|
2517
|
-
exportMode,
|
|
2518
|
-
controlsHost
|
|
2519
|
-
);
|
|
2520
|
-
}
|
|
2521
|
-
}
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
// ============================================================
|
|
2525
|
-
// Export pipeline (for CLI / d3.ts integration)
|
|
2526
|
-
// ============================================================
|
|
2527
|
-
|
|
2528
|
-
export function parseAndLayoutInfra(content: string) {
|
|
2529
|
-
const parsed = parseInfra(content);
|
|
2530
|
-
if (parsed.error) return { parsed, computed: null, layout: null };
|
|
2531
|
-
const computed = computeInfra(parsed);
|
|
2532
|
-
const layout = layoutInfra(computed);
|
|
2533
|
-
return { parsed, computed, layout };
|
|
2534
|
-
}
|