@diagrammo/dgmo 0.47.0 → 0.48.1
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/advanced.js +11 -11
- package/dist/auto.js +16 -16
- package/dist/auto.mjs +41 -12
- package/dist/block.js +4 -4
- package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
- package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
- package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
- package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
- package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
- package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
- package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
- package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
- package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
- package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
- package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
- package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
- package/dist/cli.cjs +15 -15
- package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
- package/dist/element.js +16 -16
- package/dist/element.mjs +41 -12
- package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -4
- package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
- package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
- package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
- package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
- package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
- package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
- package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
- package/package.json +23 -71
- 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/c4/renderer.ts
DELETED
|
@@ -1,2008 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// C4 Context Diagram 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 { renderInlineText } from '../utils/inline-markdown';
|
|
12
|
-
import { preprocessDescriptionLine } from '../utils/description-helpers';
|
|
13
|
-
import { measureText, wrapTextToWidth } from '../utils/text-measure';
|
|
14
|
-
import type { ParsedC4 } from './types';
|
|
15
|
-
import type { C4LayoutResult, C4LayoutEdge } from './layout';
|
|
16
|
-
import { parseC4 } from './parser';
|
|
17
|
-
import {
|
|
18
|
-
layoutC4Context,
|
|
19
|
-
layoutC4Containers,
|
|
20
|
-
layoutC4Components,
|
|
21
|
-
layoutC4Deployment,
|
|
22
|
-
collectCardMetadata,
|
|
23
|
-
} from './layout';
|
|
24
|
-
import { LEGEND_HEIGHT } from '../utils/legend-constants';
|
|
25
|
-
import { renderIntegratedLegend } from '../utils/legend-integration';
|
|
26
|
-
import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
|
|
27
|
-
|
|
28
|
-
// ============================================================
|
|
29
|
-
// Constants
|
|
30
|
-
// ============================================================
|
|
31
|
-
|
|
32
|
-
const DIAGRAM_PADDING = 20;
|
|
33
|
-
const MAX_SCALE = 3;
|
|
34
|
-
const TITLE_HEIGHT = 30;
|
|
35
|
-
const TYPE_FONT_SIZE = 10;
|
|
36
|
-
const NAME_FONT_SIZE = 14;
|
|
37
|
-
const DESC_FONT_SIZE = 11;
|
|
38
|
-
const DESC_LINE_HEIGHT = 16;
|
|
39
|
-
const EDGE_LABEL_FONT_SIZE = 11;
|
|
40
|
-
const TECH_FONT_SIZE = 10;
|
|
41
|
-
import {
|
|
42
|
-
EDGE_STROKE_WIDTH,
|
|
43
|
-
NODE_STROKE_WIDTH,
|
|
44
|
-
CARD_RADIUS,
|
|
45
|
-
META_FONT_SIZE,
|
|
46
|
-
META_LINE_HEIGHT,
|
|
47
|
-
} from '../utils/visual-conventions'; // shared (Story 111.1)
|
|
48
|
-
const CARD_H_PAD = 20;
|
|
49
|
-
const CARD_V_PAD = 14;
|
|
50
|
-
const TYPE_LABEL_HEIGHT = 18;
|
|
51
|
-
const DIVIDER_GAP = 6;
|
|
52
|
-
const NAME_HEIGHT = 20;
|
|
53
|
-
const BOUNDARY_LABEL_FONT_SIZE = 12;
|
|
54
|
-
const BOUNDARY_STROKE_WIDTH = 1.5;
|
|
55
|
-
const BOUNDARY_RADIUS = 8;
|
|
56
|
-
|
|
57
|
-
// Drillable accent bar (matches org chart collapse bar)
|
|
58
|
-
const DRILL_BAR_HEIGHT = 6;
|
|
59
|
-
|
|
60
|
-
// Cylinder (database/cache) shape constants
|
|
61
|
-
const CYLINDER_RY = 8;
|
|
62
|
-
|
|
63
|
-
// Person stick-figure dimensions (sequence-diagram style, scaled for cards)
|
|
64
|
-
const PERSON_HEAD_R = 4;
|
|
65
|
-
const PERSON_ARM_SPAN = 10;
|
|
66
|
-
const PERSON_LEG_SPAN = 7;
|
|
67
|
-
const PERSON_ICON_W = PERSON_ARM_SPAN * 2; // total width including arms
|
|
68
|
-
const PERSON_SW = 1.5;
|
|
69
|
-
|
|
70
|
-
// Legend constants (match org)
|
|
71
|
-
|
|
72
|
-
// ============================================================
|
|
73
|
-
// Color helpers
|
|
74
|
-
// ============================================================
|
|
75
|
-
|
|
76
|
-
function typeColor(
|
|
77
|
-
type: 'person' | 'system' | 'container' | 'component',
|
|
78
|
-
palette: PaletteColors,
|
|
79
|
-
nodeColor?: string
|
|
80
|
-
): string {
|
|
81
|
-
if (nodeColor) return nodeColor;
|
|
82
|
-
switch (type) {
|
|
83
|
-
case 'person':
|
|
84
|
-
return palette.colors.blue;
|
|
85
|
-
case 'container':
|
|
86
|
-
return palette.colors.purple;
|
|
87
|
-
case 'component':
|
|
88
|
-
return palette.colors.green;
|
|
89
|
-
default:
|
|
90
|
-
return palette.colors.teal;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function nodeFill(
|
|
95
|
-
palette: PaletteColors,
|
|
96
|
-
isDark: boolean,
|
|
97
|
-
type: 'person' | 'system' | 'container' | 'component',
|
|
98
|
-
nodeColor?: string,
|
|
99
|
-
solid?: boolean
|
|
100
|
-
): string {
|
|
101
|
-
const color = typeColor(type, palette, nodeColor);
|
|
102
|
-
return shapeFill(palette, color, isDark, {
|
|
103
|
-
...(solid !== undefined && { solid }),
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function nodeStroke(
|
|
108
|
-
palette: PaletteColors,
|
|
109
|
-
type: 'person' | 'system' | 'container' | 'component',
|
|
110
|
-
nodeColor?: string
|
|
111
|
-
): string {
|
|
112
|
-
return typeColor(type, palette, nodeColor);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ============================================================
|
|
116
|
-
// Edge path generator
|
|
117
|
-
// ============================================================
|
|
118
|
-
|
|
119
|
-
const lineGenerator = d3Shape
|
|
120
|
-
.line<{ x: number; y: number }>()
|
|
121
|
-
.x((d) => d.x)
|
|
122
|
-
.y((d) => d.y)
|
|
123
|
-
.curve(d3Shape.curveBasis);
|
|
124
|
-
|
|
125
|
-
// ============================================================
|
|
126
|
-
// Edge line style helpers
|
|
127
|
-
// ============================================================
|
|
128
|
-
|
|
129
|
-
function isDashedEdge(arrowType: string): boolean {
|
|
130
|
-
return arrowType === 'async' || arrowType === 'bidirectional-async';
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function hasBidirectionalMarkers(arrowType: string): boolean {
|
|
134
|
-
return arrowType === 'bidirectional' || arrowType === 'bidirectional-async';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ============================================================
|
|
138
|
-
// Person stick-figure icon
|
|
139
|
-
// ============================================================
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Stick-figure person icon matching the sequence diagram actor style.
|
|
143
|
-
* Drawn centered at (cx, cy) with total height ~22px.
|
|
144
|
-
*/
|
|
145
|
-
function drawPersonIcon(
|
|
146
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
147
|
-
cx: number,
|
|
148
|
-
cy: number,
|
|
149
|
-
color: string
|
|
150
|
-
): void {
|
|
151
|
-
const headY = cy - 7;
|
|
152
|
-
const bodyTopY = headY + PERSON_HEAD_R + 1;
|
|
153
|
-
const bodyBottomY = cy + 4;
|
|
154
|
-
const legY = cy + 10;
|
|
155
|
-
|
|
156
|
-
// Head
|
|
157
|
-
g.append('circle')
|
|
158
|
-
.attr('cx', cx)
|
|
159
|
-
.attr('cy', headY)
|
|
160
|
-
.attr('r', PERSON_HEAD_R)
|
|
161
|
-
.attr('fill', 'none')
|
|
162
|
-
.attr('stroke', color)
|
|
163
|
-
.attr('stroke-width', PERSON_SW);
|
|
164
|
-
|
|
165
|
-
// Body
|
|
166
|
-
g.append('line')
|
|
167
|
-
.attr('x1', cx)
|
|
168
|
-
.attr('y1', bodyTopY)
|
|
169
|
-
.attr('x2', cx)
|
|
170
|
-
.attr('y2', bodyBottomY)
|
|
171
|
-
.attr('stroke', color)
|
|
172
|
-
.attr('stroke-width', PERSON_SW);
|
|
173
|
-
|
|
174
|
-
// Arms
|
|
175
|
-
g.append('line')
|
|
176
|
-
.attr('x1', cx - PERSON_ARM_SPAN)
|
|
177
|
-
.attr('y1', bodyTopY + 3)
|
|
178
|
-
.attr('x2', cx + PERSON_ARM_SPAN)
|
|
179
|
-
.attr('y2', bodyTopY + 3)
|
|
180
|
-
.attr('stroke', color)
|
|
181
|
-
.attr('stroke-width', PERSON_SW);
|
|
182
|
-
|
|
183
|
-
// Left leg
|
|
184
|
-
g.append('line')
|
|
185
|
-
.attr('x1', cx)
|
|
186
|
-
.attr('y1', bodyBottomY)
|
|
187
|
-
.attr('x2', cx - PERSON_LEG_SPAN)
|
|
188
|
-
.attr('y2', legY)
|
|
189
|
-
.attr('stroke', color)
|
|
190
|
-
.attr('stroke-width', PERSON_SW);
|
|
191
|
-
|
|
192
|
-
// Right leg
|
|
193
|
-
g.append('line')
|
|
194
|
-
.attr('x1', cx)
|
|
195
|
-
.attr('y1', bodyBottomY)
|
|
196
|
-
.attr('x2', cx + PERSON_LEG_SPAN)
|
|
197
|
-
.attr('y2', legY)
|
|
198
|
-
.attr('stroke', color)
|
|
199
|
-
.attr('stroke-width', PERSON_SW);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ============================================================
|
|
203
|
-
// Main Renderer
|
|
204
|
-
// ============================================================
|
|
205
|
-
|
|
206
|
-
type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
207
|
-
|
|
208
|
-
export function renderC4Context(
|
|
209
|
-
container: HTMLDivElement,
|
|
210
|
-
parsed: ParsedC4,
|
|
211
|
-
layout: C4LayoutResult,
|
|
212
|
-
palette: PaletteColors,
|
|
213
|
-
isDark: boolean,
|
|
214
|
-
onClickItem?: (lineNumber: number) => void,
|
|
215
|
-
exportDims?: { width?: number; height?: number },
|
|
216
|
-
activeTagGroup?: string | null,
|
|
217
|
-
exportMode?: boolean
|
|
218
|
-
): void {
|
|
219
|
-
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
220
|
-
|
|
221
|
-
const width = exportDims?.width ?? container.clientWidth;
|
|
222
|
-
const height = exportDims?.height ?? container.clientHeight;
|
|
223
|
-
if (width <= 0 || height <= 0) return;
|
|
224
|
-
|
|
225
|
-
const titleHeight =
|
|
226
|
-
parsed.title && parsed.options['no-title'] !== 'on' ? TITLE_HEIGHT + 10 : 0;
|
|
227
|
-
const diagramW = layout.width;
|
|
228
|
-
const hasLegend = layout.legend.length > 0;
|
|
229
|
-
// In app mode, legend is a fixed overlay outside the scaled group.
|
|
230
|
-
// C4 layout adds MARGIN(40) + LEGEND_HEIGHT below content — remove that from diagramH.
|
|
231
|
-
const C4_LAYOUT_MARGIN = 40;
|
|
232
|
-
const LEGEND_FIXED_GAP = 8;
|
|
233
|
-
const fixedLegend = !exportDims && hasLegend;
|
|
234
|
-
const legendLayoutSpace = C4_LAYOUT_MARGIN + LEGEND_HEIGHT;
|
|
235
|
-
const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
|
|
236
|
-
const diagramH = fixedLegend
|
|
237
|
-
? layout.height - legendLayoutSpace
|
|
238
|
-
: layout.height;
|
|
239
|
-
const availH = height - titleHeight - legendReserveH;
|
|
240
|
-
const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
|
|
241
|
-
const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
|
|
242
|
-
const scale = Math.min(MAX_SCALE, scaleX, scaleY);
|
|
243
|
-
|
|
244
|
-
const scaledW = diagramW * scale;
|
|
245
|
-
const offsetX = (width - scaledW) / 2;
|
|
246
|
-
const offsetY = titleHeight + DIAGRAM_PADDING + legendReserveH;
|
|
247
|
-
|
|
248
|
-
const svg = d3Selection
|
|
249
|
-
.select(container)
|
|
250
|
-
.append('svg')
|
|
251
|
-
.attr('width', width)
|
|
252
|
-
.attr('height', height)
|
|
253
|
-
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
254
|
-
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
255
|
-
.style('font-family', FONT_FAMILY);
|
|
256
|
-
|
|
257
|
-
// ── Marker defs ──
|
|
258
|
-
const defs = svg.append('defs');
|
|
259
|
-
const AW = 10;
|
|
260
|
-
const AH = 7;
|
|
261
|
-
|
|
262
|
-
// Filled triangle — end marker
|
|
263
|
-
defs
|
|
264
|
-
.append('marker')
|
|
265
|
-
.attr('id', 'c4-arrow-end')
|
|
266
|
-
.attr('viewBox', `0 0 ${AW} ${AH}`)
|
|
267
|
-
.attr('refX', AW)
|
|
268
|
-
.attr('refY', AH / 2)
|
|
269
|
-
.attr('markerWidth', AW)
|
|
270
|
-
.attr('markerHeight', AH)
|
|
271
|
-
.attr('orient', 'auto')
|
|
272
|
-
.append('polygon')
|
|
273
|
-
.attr('points', `0,0 ${AW},${AH / 2} 0,${AH}`)
|
|
274
|
-
.attr('fill', palette.textMuted);
|
|
275
|
-
|
|
276
|
-
// Filled triangle — start marker (for bidirectional)
|
|
277
|
-
defs
|
|
278
|
-
.append('marker')
|
|
279
|
-
.attr('id', 'c4-arrow-start')
|
|
280
|
-
.attr('viewBox', `0 0 ${AW} ${AH}`)
|
|
281
|
-
.attr('refX', 0)
|
|
282
|
-
.attr('refY', AH / 2)
|
|
283
|
-
.attr('markerWidth', AW)
|
|
284
|
-
.attr('markerHeight', AH)
|
|
285
|
-
.attr('orient', 'auto')
|
|
286
|
-
.append('polygon')
|
|
287
|
-
.attr('points', `${AW},0 0,${AH / 2} ${AW},${AH}`)
|
|
288
|
-
.attr('fill', palette.textMuted);
|
|
289
|
-
|
|
290
|
-
// ── Title ──
|
|
291
|
-
if (parsed.title && parsed.options['no-title'] !== 'on') {
|
|
292
|
-
const titleEl = svg
|
|
293
|
-
.append('text')
|
|
294
|
-
.attr('class', 'chart-title')
|
|
295
|
-
.attr('x', width / 2)
|
|
296
|
-
.attr('y', 30)
|
|
297
|
-
.attr('text-anchor', 'middle')
|
|
298
|
-
.attr('fill', palette.text)
|
|
299
|
-
.attr('font-size', TITLE_FONT_SIZE)
|
|
300
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
301
|
-
.style(
|
|
302
|
-
'cursor',
|
|
303
|
-
onClickItem && parsed.titleLineNumber ? 'pointer' : 'default'
|
|
304
|
-
)
|
|
305
|
-
.text(parsed.title);
|
|
306
|
-
|
|
307
|
-
if (parsed.titleLineNumber) {
|
|
308
|
-
titleEl.attr('data-line-number', parsed.titleLineNumber);
|
|
309
|
-
if (onClickItem) {
|
|
310
|
-
titleEl
|
|
311
|
-
.on('click', () => onClickItem(parsed.titleLineNumber!))
|
|
312
|
-
.on('mouseenter', function () {
|
|
313
|
-
d3Selection.select(this).attr('opacity', 0.7);
|
|
314
|
-
})
|
|
315
|
-
.on('mouseleave', function () {
|
|
316
|
-
d3Selection.select(this).attr('opacity', 1);
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ── Content group ──
|
|
323
|
-
const contentG = svg
|
|
324
|
-
.append('g')
|
|
325
|
-
.attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
|
|
326
|
-
|
|
327
|
-
// ── Edges (behind nodes) ──
|
|
328
|
-
for (const edge of layout.edges) {
|
|
329
|
-
if (edge.points.length < 2) continue;
|
|
330
|
-
|
|
331
|
-
const edgeG = contentG
|
|
332
|
-
.append('g')
|
|
333
|
-
.attr('class', 'c4-edge-group')
|
|
334
|
-
.attr('data-line-number', String(edge.lineNumber))
|
|
335
|
-
// Endpoint node ids for baked-CSS connection-highlight (hover-styles.ts).
|
|
336
|
-
.attr('data-source', edge.source)
|
|
337
|
-
.attr('data-target', edge.target);
|
|
338
|
-
|
|
339
|
-
if (onClickItem) {
|
|
340
|
-
edgeG.style('cursor', 'pointer').on('click', () => {
|
|
341
|
-
onClickItem(edge.lineNumber);
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const edgeColor = palette.textMuted;
|
|
346
|
-
const dashed = isDashedEdge(edge.arrowType);
|
|
347
|
-
const bidir = hasBidirectionalMarkers(edge.arrowType);
|
|
348
|
-
|
|
349
|
-
const pathD = lineGenerator(edge.points);
|
|
350
|
-
if (pathD) {
|
|
351
|
-
const pathEl = edgeG
|
|
352
|
-
.append('path')
|
|
353
|
-
.attr('d', pathD)
|
|
354
|
-
.attr('fill', 'none')
|
|
355
|
-
.attr('stroke', edgeColor)
|
|
356
|
-
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
357
|
-
.attr('class', 'c4-edge')
|
|
358
|
-
.attr('marker-end', 'url(#c4-arrow-end)');
|
|
359
|
-
|
|
360
|
-
if (dashed) {
|
|
361
|
-
pathEl.attr('stroke-dasharray', '6 3');
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (bidir) {
|
|
365
|
-
pathEl.attr('marker-start', 'url(#c4-arrow-start)');
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Label at midpoint
|
|
370
|
-
if (edge.label || edge.technology) {
|
|
371
|
-
const midIdx = Math.floor(edge.points.length / 2);
|
|
372
|
-
// In-bounds: edge.points.length >= 2 guard at top of loop.
|
|
373
|
-
const midPt = edge.points[midIdx]!;
|
|
374
|
-
|
|
375
|
-
const labelText = edge.label ?? '';
|
|
376
|
-
const techText = edge.technology ? `[${edge.technology}]` : '';
|
|
377
|
-
|
|
378
|
-
// Background rect
|
|
379
|
-
const textW = Math.max(
|
|
380
|
-
measureText(labelText, EDGE_LABEL_FONT_SIZE),
|
|
381
|
-
measureText(techText, TECH_FONT_SIZE)
|
|
382
|
-
);
|
|
383
|
-
const bgW = textW + 12;
|
|
384
|
-
const bgH = (labelText ? 16 : 0) + (techText ? 14 : 0) + 4;
|
|
385
|
-
|
|
386
|
-
edgeG
|
|
387
|
-
.append('rect')
|
|
388
|
-
.attr('x', midPt.x - bgW / 2)
|
|
389
|
-
.attr('y', midPt.y - bgH / 2)
|
|
390
|
-
.attr('width', bgW)
|
|
391
|
-
.attr('height', bgH)
|
|
392
|
-
.attr('rx', 3)
|
|
393
|
-
.attr('fill', palette.bg)
|
|
394
|
-
.attr('opacity', 0.9)
|
|
395
|
-
.attr('class', 'c4-edge-label-bg');
|
|
396
|
-
|
|
397
|
-
let textY = midPt.y;
|
|
398
|
-
if (labelText && techText) {
|
|
399
|
-
textY = midPt.y - 4;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (labelText) {
|
|
403
|
-
edgeG
|
|
404
|
-
.append('text')
|
|
405
|
-
.attr('x', midPt.x)
|
|
406
|
-
.attr('y', textY + 4)
|
|
407
|
-
.attr('text-anchor', 'middle')
|
|
408
|
-
.attr('fill', edgeColor)
|
|
409
|
-
.attr('font-size', EDGE_LABEL_FONT_SIZE)
|
|
410
|
-
.attr('class', 'c4-edge-label')
|
|
411
|
-
.text(labelText);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (techText) {
|
|
415
|
-
edgeG
|
|
416
|
-
.append('text')
|
|
417
|
-
.attr('x', midPt.x)
|
|
418
|
-
.attr('y', labelText ? textY + 18 : textY + 4)
|
|
419
|
-
.attr('text-anchor', 'middle')
|
|
420
|
-
.attr('fill', edgeColor)
|
|
421
|
-
.attr('font-size', TECH_FONT_SIZE)
|
|
422
|
-
.attr('font-style', 'italic')
|
|
423
|
-
.attr('class', 'c4-edge-tech')
|
|
424
|
-
.text(techText);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// ── Nodes (top layer) ──
|
|
430
|
-
for (const node of layout.nodes) {
|
|
431
|
-
const nodeG = contentG
|
|
432
|
-
.append('g')
|
|
433
|
-
.attr('transform', `translate(${node.x}, ${node.y})`)
|
|
434
|
-
.attr('class', 'c4-card')
|
|
435
|
-
.attr('data-line-number', String(node.lineNumber))
|
|
436
|
-
.attr('data-node-id', node.id);
|
|
437
|
-
|
|
438
|
-
if (activeTagGroup) {
|
|
439
|
-
const tagKey = tagAttrKey(activeTagGroup);
|
|
440
|
-
const tagValue = node.metadata[tagKey];
|
|
441
|
-
if (tagValue) {
|
|
442
|
-
nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
|
|
443
|
-
} else {
|
|
444
|
-
// Fall back to the group's defaultValue so hover-dimming works for
|
|
445
|
-
// nodes that inherit the default (e.g. sc: Internal default).
|
|
446
|
-
const tagGroup = parsed.tagGroups.find(
|
|
447
|
-
(g) =>
|
|
448
|
-
g.name.toLowerCase() === tagKey || g.alias?.toLowerCase() === tagKey
|
|
449
|
-
);
|
|
450
|
-
if (tagGroup?.defaultValue) {
|
|
451
|
-
nodeG.attr(`data-tag-${tagKey}`, tagGroup.defaultValue.toLowerCase());
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (node.importPath) {
|
|
457
|
-
nodeG.attr('data-import-path', node.importPath);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (onClickItem) {
|
|
461
|
-
nodeG.style('cursor', 'pointer').on('click', () => {
|
|
462
|
-
onClickItem(node.lineNumber);
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
const w = node.width;
|
|
467
|
-
const h = node.height;
|
|
468
|
-
const solid = parsed.options['solid-fill'] === 'on';
|
|
469
|
-
const fill = nodeFill(palette, isDark, node.type, node.color, solid);
|
|
470
|
-
const stroke = nodeStroke(palette, node.type, node.color);
|
|
471
|
-
const onFillText = contrastText(
|
|
472
|
-
fill,
|
|
473
|
-
palette.textOnFillLight,
|
|
474
|
-
palette.textOnFillDark
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
// Card background
|
|
478
|
-
nodeG
|
|
479
|
-
.append('rect')
|
|
480
|
-
.attr('x', -w / 2)
|
|
481
|
-
.attr('y', -h / 2)
|
|
482
|
-
.attr('width', w)
|
|
483
|
-
.attr('height', h)
|
|
484
|
-
.attr('rx', CARD_RADIUS)
|
|
485
|
-
.attr('ry', CARD_RADIUS)
|
|
486
|
-
.attr('fill', fill)
|
|
487
|
-
.attr('stroke', stroke)
|
|
488
|
-
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
489
|
-
|
|
490
|
-
let yPos = -h / 2 + CARD_V_PAD;
|
|
491
|
-
|
|
492
|
-
// Type label (e.g. «person» or «system»)
|
|
493
|
-
const typeLabel = `\u00AB${node.type}\u00BB`;
|
|
494
|
-
nodeG
|
|
495
|
-
.append('text')
|
|
496
|
-
.attr('x', 0)
|
|
497
|
-
.attr('y', yPos + TYPE_FONT_SIZE / 2)
|
|
498
|
-
.attr('text-anchor', 'middle')
|
|
499
|
-
.attr('dominant-baseline', 'central')
|
|
500
|
-
.attr('fill', onFillText)
|
|
501
|
-
.attr('font-size', TYPE_FONT_SIZE)
|
|
502
|
-
.attr('font-style', 'italic')
|
|
503
|
-
.text(typeLabel);
|
|
504
|
-
|
|
505
|
-
yPos += TYPE_LABEL_HEIGHT;
|
|
506
|
-
|
|
507
|
-
// Name (bold) — above divider
|
|
508
|
-
if (node.type === 'person') {
|
|
509
|
-
// Person icon to the left of name
|
|
510
|
-
const textWidth = measureText(node.name, NAME_FONT_SIZE);
|
|
511
|
-
const gap = 6;
|
|
512
|
-
const totalWidth = PERSON_ICON_W + gap + textWidth;
|
|
513
|
-
const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
|
|
514
|
-
const textX = iconCx + PERSON_ICON_W / 2 + gap;
|
|
515
|
-
|
|
516
|
-
drawPersonIcon(
|
|
517
|
-
nodeG as GSelection,
|
|
518
|
-
iconCx,
|
|
519
|
-
yPos + NAME_FONT_SIZE / 2 - 2,
|
|
520
|
-
stroke
|
|
521
|
-
);
|
|
522
|
-
|
|
523
|
-
nodeG
|
|
524
|
-
.append('text')
|
|
525
|
-
.attr('x', textX)
|
|
526
|
-
.attr('y', yPos + NAME_FONT_SIZE / 2)
|
|
527
|
-
.attr('text-anchor', 'start')
|
|
528
|
-
.attr('dominant-baseline', 'central')
|
|
529
|
-
.attr('fill', onFillText)
|
|
530
|
-
.attr('font-size', NAME_FONT_SIZE)
|
|
531
|
-
.attr('font-weight', 'bold')
|
|
532
|
-
.text(node.name);
|
|
533
|
-
} else {
|
|
534
|
-
nodeG
|
|
535
|
-
.append('text')
|
|
536
|
-
.attr('x', 0)
|
|
537
|
-
.attr('y', yPos + NAME_FONT_SIZE / 2)
|
|
538
|
-
.attr('text-anchor', 'middle')
|
|
539
|
-
.attr('dominant-baseline', 'central')
|
|
540
|
-
.attr('fill', onFillText)
|
|
541
|
-
.attr('font-size', NAME_FONT_SIZE)
|
|
542
|
-
.attr('font-weight', 'bold')
|
|
543
|
-
.text(node.name);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
yPos += NAME_HEIGHT;
|
|
547
|
-
|
|
548
|
-
// Subtle divider — between name and description
|
|
549
|
-
nodeG
|
|
550
|
-
.append('line')
|
|
551
|
-
.attr('x1', -w / 2 + CARD_H_PAD / 2)
|
|
552
|
-
.attr('y1', yPos)
|
|
553
|
-
.attr('x2', w / 2 - CARD_H_PAD / 2)
|
|
554
|
-
.attr('y2', yPos)
|
|
555
|
-
.attr('stroke', solid ? onFillText : stroke)
|
|
556
|
-
.attr('stroke-width', 0.5)
|
|
557
|
-
.attr('stroke-opacity', 0.4);
|
|
558
|
-
|
|
559
|
-
yPos += DIVIDER_GAP;
|
|
560
|
-
|
|
561
|
-
// Description (wrapping, inline markdown) — must contrast against fill
|
|
562
|
-
if (node.description) {
|
|
563
|
-
const contentWidth = w - CARD_H_PAD * 2;
|
|
564
|
-
const lines = wrapTextToWidth(
|
|
565
|
-
node.description,
|
|
566
|
-
DESC_FONT_SIZE,
|
|
567
|
-
contentWidth
|
|
568
|
-
);
|
|
569
|
-
for (const line of lines) {
|
|
570
|
-
const textEl = nodeG
|
|
571
|
-
.append('text')
|
|
572
|
-
.attr('x', 0)
|
|
573
|
-
.attr('y', yPos + DESC_FONT_SIZE / 2)
|
|
574
|
-
.attr('text-anchor', 'middle')
|
|
575
|
-
.attr('dominant-baseline', 'central')
|
|
576
|
-
.attr('fill', onFillText)
|
|
577
|
-
.attr('font-size', DESC_FONT_SIZE);
|
|
578
|
-
renderInlineText(
|
|
579
|
-
textEl,
|
|
580
|
-
preprocessDescriptionLine(line),
|
|
581
|
-
palette,
|
|
582
|
-
DESC_FONT_SIZE
|
|
583
|
-
);
|
|
584
|
-
yPos += DESC_LINE_HEIGHT;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Drillable accent bar — solid bar at bottom of card, clipped to rounded corners
|
|
589
|
-
if (node.drillable) {
|
|
590
|
-
const clipId = `clip-drill-${node.id.replace(/\s+/g, '-')}`;
|
|
591
|
-
nodeG
|
|
592
|
-
.append('clipPath')
|
|
593
|
-
.attr('id', clipId)
|
|
594
|
-
.append('rect')
|
|
595
|
-
.attr('x', -w / 2)
|
|
596
|
-
.attr('y', -h / 2)
|
|
597
|
-
.attr('width', w)
|
|
598
|
-
.attr('height', h)
|
|
599
|
-
.attr('rx', CARD_RADIUS);
|
|
600
|
-
nodeG
|
|
601
|
-
.append('rect')
|
|
602
|
-
.attr('x', -w / 2)
|
|
603
|
-
.attr('y', h / 2 - DRILL_BAR_HEIGHT)
|
|
604
|
-
.attr('width', w)
|
|
605
|
-
.attr('height', DRILL_BAR_HEIGHT)
|
|
606
|
-
// In solid mode, `stroke` matches the fill — drill-bar disappears.
|
|
607
|
-
// Use the contrast text color so the indicator stays visible.
|
|
608
|
-
.attr('fill', solid ? onFillText : stroke)
|
|
609
|
-
.attr('clip-path', `url(#${clipId})`)
|
|
610
|
-
.attr('class', 'c4-drill-bar');
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// ── Legend ──
|
|
615
|
-
if (hasLegend) {
|
|
616
|
-
// App mode: fixed overlay at SVG top so it's always readable regardless of scale.
|
|
617
|
-
// Export mode: render inside scaled contentG at layout coordinates.
|
|
618
|
-
const legendParent = fixedLegend
|
|
619
|
-
? svg
|
|
620
|
-
.append('g')
|
|
621
|
-
.attr('class', 'c4-legend-fixed')
|
|
622
|
-
.attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
|
|
623
|
-
: contentG.append('g').attr('class', 'c4-legend');
|
|
624
|
-
if (activeTagGroup) {
|
|
625
|
-
legendParent.attr('data-legend-active', tagAttrKey(activeTagGroup));
|
|
626
|
-
}
|
|
627
|
-
renderLegend(
|
|
628
|
-
legendParent as GSelection,
|
|
629
|
-
layout,
|
|
630
|
-
palette,
|
|
631
|
-
isDark,
|
|
632
|
-
activeTagGroup,
|
|
633
|
-
fixedLegend ? width : null,
|
|
634
|
-
exportMode
|
|
635
|
-
);
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// ============================================================
|
|
640
|
-
// Export convenience function
|
|
641
|
-
// ============================================================
|
|
642
|
-
|
|
643
|
-
export function renderC4ContextForExport(
|
|
644
|
-
content: string,
|
|
645
|
-
theme: 'light' | 'dark' | 'transparent',
|
|
646
|
-
palette: PaletteColors
|
|
647
|
-
): string {
|
|
648
|
-
const parsed = parseC4(content, palette);
|
|
649
|
-
if (parsed.error || parsed.elements.length === 0) return '';
|
|
650
|
-
|
|
651
|
-
const layout = layoutC4Context(parsed);
|
|
652
|
-
const isDark = theme === 'dark';
|
|
653
|
-
|
|
654
|
-
const container = document.createElement('div');
|
|
655
|
-
const titleOffset =
|
|
656
|
-
parsed.title && parsed.options['no-title'] !== 'on' ? TITLE_HEIGHT + 10 : 0;
|
|
657
|
-
const exportWidth = layout.width + DIAGRAM_PADDING * 2;
|
|
658
|
-
const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
|
|
659
|
-
|
|
660
|
-
container.style.width = `${exportWidth}px`;
|
|
661
|
-
container.style.height = `${exportHeight}px`;
|
|
662
|
-
container.style.position = 'absolute';
|
|
663
|
-
container.style.left = '-9999px';
|
|
664
|
-
document.body.appendChild(container);
|
|
665
|
-
|
|
666
|
-
try {
|
|
667
|
-
renderC4Context(container, parsed, layout, palette, isDark, undefined, {
|
|
668
|
-
width: exportWidth,
|
|
669
|
-
height: exportHeight,
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
const svgEl = container.querySelector('svg');
|
|
673
|
-
if (!svgEl) return '';
|
|
674
|
-
|
|
675
|
-
if (theme === 'transparent') {
|
|
676
|
-
svgEl.style.background = 'none';
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
680
|
-
svgEl.style.fontFamily = FONT_FAMILY;
|
|
681
|
-
|
|
682
|
-
return svgEl.outerHTML;
|
|
683
|
-
} finally {
|
|
684
|
-
document.body.removeChild(container);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// ============================================================
|
|
689
|
-
// Shape card backgrounds
|
|
690
|
-
// ============================================================
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* Draw a cylinder-shaped card background (for database/cache shapes).
|
|
694
|
-
* Replaces the simple rounded rect with a cylinder shape.
|
|
695
|
-
*/
|
|
696
|
-
function drawCylinderCard(
|
|
697
|
-
nodeG: GSelection,
|
|
698
|
-
w: number,
|
|
699
|
-
h: number,
|
|
700
|
-
fill: string,
|
|
701
|
-
stroke: string,
|
|
702
|
-
dashed: boolean
|
|
703
|
-
): void {
|
|
704
|
-
const ry = CYLINDER_RY;
|
|
705
|
-
// Build cylinder path: top ellipse, sides, bottom ellipse
|
|
706
|
-
const path = [
|
|
707
|
-
`M ${-w / 2} ${-h / 2 + ry}`,
|
|
708
|
-
`A ${w / 2} ${ry} 0 0 1 ${w / 2} ${-h / 2 + ry}`,
|
|
709
|
-
`L ${w / 2} ${h / 2 - ry}`,
|
|
710
|
-
`A ${w / 2} ${ry} 0 0 1 ${-w / 2} ${h / 2 - ry}`,
|
|
711
|
-
'Z',
|
|
712
|
-
].join(' ');
|
|
713
|
-
|
|
714
|
-
const el = nodeG
|
|
715
|
-
.append('path')
|
|
716
|
-
.attr('d', path)
|
|
717
|
-
.attr('fill', fill)
|
|
718
|
-
.attr('stroke', stroke)
|
|
719
|
-
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
720
|
-
|
|
721
|
-
if (dashed) {
|
|
722
|
-
el.attr('stroke-dasharray', '6 3');
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Top ellipse highlight (inner curve)
|
|
726
|
-
nodeG
|
|
727
|
-
.append('ellipse')
|
|
728
|
-
.attr('cx', 0)
|
|
729
|
-
.attr('cy', -h / 2 + ry)
|
|
730
|
-
.attr('rx', w / 2)
|
|
731
|
-
.attr('ry', ry)
|
|
732
|
-
.attr('fill', fill)
|
|
733
|
-
.attr('stroke', stroke)
|
|
734
|
-
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* Draw a standard card background rect, optionally dashed.
|
|
739
|
-
*/
|
|
740
|
-
function drawCardRect(
|
|
741
|
-
nodeG: GSelection,
|
|
742
|
-
w: number,
|
|
743
|
-
h: number,
|
|
744
|
-
fill: string,
|
|
745
|
-
stroke: string,
|
|
746
|
-
dashed: boolean
|
|
747
|
-
): void {
|
|
748
|
-
const el = nodeG
|
|
749
|
-
.append('rect')
|
|
750
|
-
.attr('x', -w / 2)
|
|
751
|
-
.attr('y', -h / 2)
|
|
752
|
-
.attr('width', w)
|
|
753
|
-
.attr('height', h)
|
|
754
|
-
.attr('rx', CARD_RADIUS)
|
|
755
|
-
.attr('ry', CARD_RADIUS)
|
|
756
|
-
.attr('fill', fill)
|
|
757
|
-
.attr('stroke', stroke)
|
|
758
|
-
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
759
|
-
|
|
760
|
-
if (dashed) {
|
|
761
|
-
el.attr('stroke-dasharray', '6 3');
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// ============================================================
|
|
766
|
-
// Shared rendering helpers
|
|
767
|
-
// ============================================================
|
|
768
|
-
|
|
769
|
-
function renderEdges(
|
|
770
|
-
contentG: GSelection,
|
|
771
|
-
edges: readonly C4LayoutEdge[],
|
|
772
|
-
palette: PaletteColors,
|
|
773
|
-
onClickItem?: (lineNumber: number) => void,
|
|
774
|
-
obstacleRects?: { x: number; y: number; w: number; h: number }[]
|
|
775
|
-
): void {
|
|
776
|
-
// Collect labels for deferred rendering with collision avoidance
|
|
777
|
-
const pendingLabels: {
|
|
778
|
-
edgeG: GSelection;
|
|
779
|
-
labelText: string;
|
|
780
|
-
techText: string;
|
|
781
|
-
bgW: number;
|
|
782
|
-
bgH: number;
|
|
783
|
-
x: number;
|
|
784
|
-
y: number;
|
|
785
|
-
edgeColor: string;
|
|
786
|
-
edgeIdx: number;
|
|
787
|
-
}[] = [];
|
|
788
|
-
|
|
789
|
-
for (const edge of edges) {
|
|
790
|
-
if (edge.points.length < 2) continue;
|
|
791
|
-
|
|
792
|
-
const edgeG = contentG
|
|
793
|
-
.append('g')
|
|
794
|
-
.attr('class', 'c4-edge-group')
|
|
795
|
-
.attr('data-line-number', String(edge.lineNumber))
|
|
796
|
-
// Endpoint node ids for baked-CSS connection-highlight (hover-styles.ts).
|
|
797
|
-
.attr('data-source', edge.source)
|
|
798
|
-
.attr('data-target', edge.target);
|
|
799
|
-
|
|
800
|
-
if (onClickItem) {
|
|
801
|
-
edgeG.style('cursor', 'pointer').on('click', () => {
|
|
802
|
-
onClickItem(edge.lineNumber);
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const edgeColor = palette.textMuted;
|
|
807
|
-
const dashed = isDashedEdge(edge.arrowType);
|
|
808
|
-
const bidir = hasBidirectionalMarkers(edge.arrowType);
|
|
809
|
-
|
|
810
|
-
const pathD = lineGenerator(edge.points);
|
|
811
|
-
if (pathD) {
|
|
812
|
-
const pathEl = edgeG
|
|
813
|
-
.append('path')
|
|
814
|
-
.attr('d', pathD)
|
|
815
|
-
.attr('fill', 'none')
|
|
816
|
-
.attr('stroke', edgeColor)
|
|
817
|
-
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
818
|
-
.attr('class', 'c4-edge')
|
|
819
|
-
.attr('marker-end', 'url(#c4-arrow-end)');
|
|
820
|
-
|
|
821
|
-
if (dashed) {
|
|
822
|
-
pathEl.attr('stroke-dasharray', '6 3');
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
if (bidir) {
|
|
826
|
-
pathEl.attr('marker-start', 'url(#c4-arrow-start)');
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// Collect label info for deferred placement
|
|
831
|
-
if (edge.label || edge.technology) {
|
|
832
|
-
const labelText = edge.label ?? '';
|
|
833
|
-
const techText = edge.technology ? `[${edge.technology}]` : '';
|
|
834
|
-
const textW = Math.max(
|
|
835
|
-
measureText(labelText, EDGE_LABEL_FONT_SIZE),
|
|
836
|
-
measureText(techText, TECH_FONT_SIZE)
|
|
837
|
-
);
|
|
838
|
-
const bgW = textW + 12;
|
|
839
|
-
const bgH = (labelText ? 16 : 0) + (techText ? 14 : 0) + 4;
|
|
840
|
-
|
|
841
|
-
pendingLabels.push({
|
|
842
|
-
edgeG,
|
|
843
|
-
labelText,
|
|
844
|
-
techText,
|
|
845
|
-
bgW,
|
|
846
|
-
bgH,
|
|
847
|
-
x: 0,
|
|
848
|
-
y: 0,
|
|
849
|
-
edgeColor,
|
|
850
|
-
edgeIdx: edges.indexOf(edge),
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
// Place labels using maximum-clearance algorithm: for each label,
|
|
856
|
-
// find the position along its own edge that is farthest from all
|
|
857
|
-
// other edges and already-placed labels.
|
|
858
|
-
placeEdgeLabels(pendingLabels, edges, obstacleRects);
|
|
859
|
-
|
|
860
|
-
// Render all labels
|
|
861
|
-
for (const lbl of pendingLabels) {
|
|
862
|
-
lbl.edgeG
|
|
863
|
-
.append('rect')
|
|
864
|
-
.attr('x', lbl.x - lbl.bgW / 2)
|
|
865
|
-
.attr('y', lbl.y - lbl.bgH / 2)
|
|
866
|
-
.attr('width', lbl.bgW)
|
|
867
|
-
.attr('height', lbl.bgH)
|
|
868
|
-
.attr('rx', 3)
|
|
869
|
-
.attr('fill', palette.bg)
|
|
870
|
-
.attr('opacity', 0.9)
|
|
871
|
-
.attr('class', 'c4-edge-label-bg');
|
|
872
|
-
|
|
873
|
-
let textY = lbl.y;
|
|
874
|
-
if (lbl.labelText && lbl.techText) {
|
|
875
|
-
textY = lbl.y - 4;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
if (lbl.labelText) {
|
|
879
|
-
lbl.edgeG
|
|
880
|
-
.append('text')
|
|
881
|
-
.attr('x', lbl.x)
|
|
882
|
-
.attr('y', textY + 4)
|
|
883
|
-
.attr('text-anchor', 'middle')
|
|
884
|
-
.attr('fill', lbl.edgeColor)
|
|
885
|
-
.attr('font-size', EDGE_LABEL_FONT_SIZE)
|
|
886
|
-
.attr('class', 'c4-edge-label')
|
|
887
|
-
.text(lbl.labelText);
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
if (lbl.techText) {
|
|
891
|
-
lbl.edgeG
|
|
892
|
-
.append('text')
|
|
893
|
-
.attr('x', lbl.x)
|
|
894
|
-
.attr('y', lbl.labelText ? textY + 18 : textY + 4)
|
|
895
|
-
.attr('text-anchor', 'middle')
|
|
896
|
-
.attr('fill', lbl.edgeColor)
|
|
897
|
-
.attr('font-size', TECH_FONT_SIZE)
|
|
898
|
-
.attr('font-style', 'italic')
|
|
899
|
-
.attr('class', 'c4-edge-tech')
|
|
900
|
-
.text(lbl.techText);
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// ============================================================
|
|
906
|
-
// Edge Label Placement (Maximum Clearance)
|
|
907
|
-
// ============================================================
|
|
908
|
-
|
|
909
|
-
/** Interpolate a point at fraction t (0–1) along a polyline path. */
|
|
910
|
-
function interpolateAlongPath(
|
|
911
|
-
points: readonly { readonly x: number; readonly y: number }[],
|
|
912
|
-
t: number
|
|
913
|
-
): { x: number; y: number } {
|
|
914
|
-
if (points.length < 2) return points[0]!;
|
|
915
|
-
|
|
916
|
-
let totalLen = 0;
|
|
917
|
-
for (let i = 1; i < points.length; i++) {
|
|
918
|
-
const dx = points[i]!.x - points[i - 1]!.x;
|
|
919
|
-
const dy = points[i]!.y - points[i - 1]!.y;
|
|
920
|
-
totalLen += Math.sqrt(dx * dx + dy * dy);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
const targetLen = t * totalLen;
|
|
924
|
-
let accumulated = 0;
|
|
925
|
-
for (let i = 1; i < points.length; i++) {
|
|
926
|
-
const dx = points[i]!.x - points[i - 1]!.x;
|
|
927
|
-
const dy = points[i]!.y - points[i - 1]!.y;
|
|
928
|
-
const segLen = Math.sqrt(dx * dx + dy * dy);
|
|
929
|
-
if (accumulated + segLen >= targetLen) {
|
|
930
|
-
const segT = segLen > 0 ? (targetLen - accumulated) / segLen : 0;
|
|
931
|
-
return {
|
|
932
|
-
x: points[i - 1]!.x + dx * segT,
|
|
933
|
-
y: points[i - 1]!.y + dy * segT,
|
|
934
|
-
};
|
|
935
|
-
}
|
|
936
|
-
accumulated += segLen;
|
|
937
|
-
}
|
|
938
|
-
return points[points.length - 1]!;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
/** Minimum distance from point p to a line segment (a, b). */
|
|
942
|
-
function pointToSegmentDist(
|
|
943
|
-
p: { x: number; y: number },
|
|
944
|
-
a: { x: number; y: number },
|
|
945
|
-
b: { x: number; y: number }
|
|
946
|
-
): number {
|
|
947
|
-
const dx = b.x - a.x;
|
|
948
|
-
const dy = b.y - a.y;
|
|
949
|
-
const lenSq = dx * dx + dy * dy;
|
|
950
|
-
if (lenSq === 0) {
|
|
951
|
-
const ex = p.x - a.x;
|
|
952
|
-
const ey = p.y - a.y;
|
|
953
|
-
return Math.sqrt(ex * ex + ey * ey);
|
|
954
|
-
}
|
|
955
|
-
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
|
|
956
|
-
t = Math.max(0, Math.min(1, t));
|
|
957
|
-
const projX = a.x + t * dx;
|
|
958
|
-
const projY = a.y + t * dy;
|
|
959
|
-
const ex = p.x - projX;
|
|
960
|
-
const ey = p.y - projY;
|
|
961
|
-
return Math.sqrt(ex * ex + ey * ey);
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
/** Minimum distance from point p to a polyline path. */
|
|
965
|
-
function pointToPolylineDist(
|
|
966
|
-
p: { x: number; y: number },
|
|
967
|
-
points: readonly { readonly x: number; readonly y: number }[]
|
|
968
|
-
): number {
|
|
969
|
-
let minDist = Infinity;
|
|
970
|
-
for (let i = 1; i < points.length; i++) {
|
|
971
|
-
const d = pointToSegmentDist(p, points[i - 1]!, points[i]!);
|
|
972
|
-
if (d < minDist) minDist = d;
|
|
973
|
-
}
|
|
974
|
-
return minDist;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
/** Check if a rect overlaps another rect. */
|
|
978
|
-
function rectsOverlap(
|
|
979
|
-
ax: number,
|
|
980
|
-
ay: number,
|
|
981
|
-
aw: number,
|
|
982
|
-
ah: number,
|
|
983
|
-
bx: number,
|
|
984
|
-
by: number,
|
|
985
|
-
bw: number,
|
|
986
|
-
bh: number,
|
|
987
|
-
pad: number
|
|
988
|
-
): boolean {
|
|
989
|
-
return !(
|
|
990
|
-
ax + aw / 2 + pad < bx - bw / 2 - pad ||
|
|
991
|
-
bx + bw / 2 + pad < ax - aw / 2 - pad ||
|
|
992
|
-
ay + ah / 2 + pad < by - bh / 2 - pad ||
|
|
993
|
-
by + bh / 2 + pad < ay - ah / 2 - pad
|
|
994
|
-
);
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
/**
|
|
998
|
-
* Place edge labels using maximum-clearance algorithm.
|
|
999
|
-
*
|
|
1000
|
-
* For each edge with a label, samples candidate positions along the edge
|
|
1001
|
-
* path (avoiding the endpoints near nodes) and scores each by:
|
|
1002
|
-
* 1. Minimum distance to all OTHER edge paths (want: far from other lines)
|
|
1003
|
-
* 2. No overlap with already-placed labels (hard constraint)
|
|
1004
|
-
*
|
|
1005
|
-
* Labels are placed greedily: edges with fewer good positions go first.
|
|
1006
|
-
*/
|
|
1007
|
-
/** Compute the tangent direction at fraction t along a polyline. */
|
|
1008
|
-
function tangentAt(
|
|
1009
|
-
points: readonly { readonly x: number; readonly y: number }[],
|
|
1010
|
-
t: number
|
|
1011
|
-
): { x: number; y: number } {
|
|
1012
|
-
if (points.length < 2) return { x: 0, y: 1 };
|
|
1013
|
-
let totalLen = 0;
|
|
1014
|
-
for (let i = 1; i < points.length; i++) {
|
|
1015
|
-
const dx = points[i]!.x - points[i - 1]!.x;
|
|
1016
|
-
const dy = points[i]!.y - points[i - 1]!.y;
|
|
1017
|
-
totalLen += Math.sqrt(dx * dx + dy * dy);
|
|
1018
|
-
}
|
|
1019
|
-
const targetLen = t * totalLen;
|
|
1020
|
-
let accumulated = 0;
|
|
1021
|
-
for (let i = 1; i < points.length; i++) {
|
|
1022
|
-
const dx = points[i]!.x - points[i - 1]!.x;
|
|
1023
|
-
const dy = points[i]!.y - points[i - 1]!.y;
|
|
1024
|
-
const segLen = Math.sqrt(dx * dx + dy * dy);
|
|
1025
|
-
if (accumulated + segLen >= targetLen || i === points.length - 1) {
|
|
1026
|
-
return { x: dx, y: dy };
|
|
1027
|
-
}
|
|
1028
|
-
accumulated += segLen;
|
|
1029
|
-
}
|
|
1030
|
-
return { x: 0, y: 1 };
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
function placeEdgeLabels(
|
|
1034
|
-
labels: {
|
|
1035
|
-
edgeIdx: number;
|
|
1036
|
-
bgW: number;
|
|
1037
|
-
bgH: number;
|
|
1038
|
-
x: number;
|
|
1039
|
-
y: number;
|
|
1040
|
-
}[],
|
|
1041
|
-
edges: readonly C4LayoutEdge[],
|
|
1042
|
-
obstacleRects?: { x: number; y: number; w: number; h: number }[]
|
|
1043
|
-
): void {
|
|
1044
|
-
if (labels.length === 0) return;
|
|
1045
|
-
|
|
1046
|
-
// Collect all edge polylines
|
|
1047
|
-
const allPaths = edges.map((e) => e.points);
|
|
1048
|
-
|
|
1049
|
-
// Already-placed label rects for overlap checking
|
|
1050
|
-
const placedRects: { x: number; y: number; w: number; h: number }[] = [];
|
|
1051
|
-
|
|
1052
|
-
// Bias samples toward target end (50–90%) where edges have diverged
|
|
1053
|
-
const SAMPLES = [0.4, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9];
|
|
1054
|
-
|
|
1055
|
-
// Pre-compute candidate positions for each label
|
|
1056
|
-
const candidates = labels.map((lbl) => {
|
|
1057
|
-
const ownPath = allPaths[lbl.edgeIdx]!;
|
|
1058
|
-
return SAMPLES.map((t) => ({ pt: interpolateAlongPath(ownPath, t), t }));
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
// Place greedily — label with fewest high-scoring candidates goes first
|
|
1062
|
-
const order = labels.map((_, i) => i);
|
|
1063
|
-
|
|
1064
|
-
// Sort: labels on edges with more nearby edges go first (hardest to place)
|
|
1065
|
-
order.sort((a, b) => {
|
|
1066
|
-
const midA = interpolateAlongPath(allPaths[labels[a]!.edgeIdx]!, 0.5);
|
|
1067
|
-
const midB = interpolateAlongPath(allPaths[labels[b]!.edgeIdx]!, 0.5);
|
|
1068
|
-
let nearA = 0,
|
|
1069
|
-
nearB = 0;
|
|
1070
|
-
for (let e = 0; e < allPaths.length; e++) {
|
|
1071
|
-
if (e === labels[a]!.edgeIdx) continue;
|
|
1072
|
-
if (pointToPolylineDist(midA, allPaths[e]!) < 100) nearA++;
|
|
1073
|
-
}
|
|
1074
|
-
for (let e = 0; e < allPaths.length; e++) {
|
|
1075
|
-
if (e === labels[b]!.edgeIdx) continue;
|
|
1076
|
-
if (pointToPolylineDist(midB, allPaths[e]!) < 100) nearB++;
|
|
1077
|
-
}
|
|
1078
|
-
return nearB - nearA; // Most constrained first
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
for (const idx of order) {
|
|
1082
|
-
const lbl = labels[idx]!;
|
|
1083
|
-
const ownEdgeIdx = lbl.edgeIdx;
|
|
1084
|
-
const ownPath = allPaths[ownEdgeIdx]!;
|
|
1085
|
-
const cands = candidates[idx]!;
|
|
1086
|
-
|
|
1087
|
-
let bestScore = -Infinity;
|
|
1088
|
-
let bestPt = cands[Math.floor(cands.length / 2)]!.pt;
|
|
1089
|
-
let bestT = 0.5;
|
|
1090
|
-
|
|
1091
|
-
for (const { pt, t } of cands) {
|
|
1092
|
-
// Min distance to all OTHER edge paths
|
|
1093
|
-
let minEdgeDist = Infinity;
|
|
1094
|
-
for (let e = 0; e < allPaths.length; e++) {
|
|
1095
|
-
if (e === ownEdgeIdx) continue;
|
|
1096
|
-
const d = pointToPolylineDist(pt, allPaths[e]!);
|
|
1097
|
-
if (d < minEdgeDist) minEdgeDist = d;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// Penalty for overlapping already-placed labels
|
|
1101
|
-
let labelOverlapPenalty = 0;
|
|
1102
|
-
for (const placed of placedRects) {
|
|
1103
|
-
if (
|
|
1104
|
-
rectsOverlap(
|
|
1105
|
-
pt.x,
|
|
1106
|
-
pt.y,
|
|
1107
|
-
lbl.bgW,
|
|
1108
|
-
lbl.bgH,
|
|
1109
|
-
placed.x,
|
|
1110
|
-
placed.y,
|
|
1111
|
-
placed.w,
|
|
1112
|
-
placed.h,
|
|
1113
|
-
6
|
|
1114
|
-
)
|
|
1115
|
-
) {
|
|
1116
|
-
labelOverlapPenalty += 200;
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// Penalty for overlapping boundary/obstacle rects (e.g. boundary labels)
|
|
1121
|
-
if (obstacleRects) {
|
|
1122
|
-
for (const obs of obstacleRects) {
|
|
1123
|
-
if (
|
|
1124
|
-
rectsOverlap(
|
|
1125
|
-
pt.x,
|
|
1126
|
-
pt.y,
|
|
1127
|
-
lbl.bgW,
|
|
1128
|
-
lbl.bgH,
|
|
1129
|
-
obs.x + obs.w / 2,
|
|
1130
|
-
obs.y + obs.h / 2,
|
|
1131
|
-
obs.w,
|
|
1132
|
-
obs.h,
|
|
1133
|
-
6
|
|
1134
|
-
)
|
|
1135
|
-
) {
|
|
1136
|
-
labelOverlapPenalty += 200;
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
const score = minEdgeDist - labelOverlapPenalty;
|
|
1142
|
-
if (score > bestScore) {
|
|
1143
|
-
bestScore = score;
|
|
1144
|
-
bestPt = pt;
|
|
1145
|
-
bestT = t;
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// Perpendicular offset: push label to the side of its edge with more
|
|
1150
|
-
// clearance from other edges. This makes it unambiguous which line a
|
|
1151
|
-
// label belongs to even when edges are close together.
|
|
1152
|
-
const tan = tangentAt(ownPath, bestT);
|
|
1153
|
-
const tLen = Math.sqrt(tan.x * tan.x + tan.y * tan.y);
|
|
1154
|
-
if (tLen > 0) {
|
|
1155
|
-
// Normal perpendicular to edge tangent
|
|
1156
|
-
const nx = -tan.y / tLen;
|
|
1157
|
-
const ny = tan.x / tLen;
|
|
1158
|
-
const offsetDist = lbl.bgH / 2 + 4;
|
|
1159
|
-
const sideA = {
|
|
1160
|
-
x: bestPt.x + nx * offsetDist,
|
|
1161
|
-
y: bestPt.y + ny * offsetDist,
|
|
1162
|
-
};
|
|
1163
|
-
const sideB = {
|
|
1164
|
-
x: bestPt.x - nx * offsetDist,
|
|
1165
|
-
y: bestPt.y - ny * offsetDist,
|
|
1166
|
-
};
|
|
1167
|
-
|
|
1168
|
-
// Score each side: clearance from other edges + overlap with placed labels
|
|
1169
|
-
let scoreA = Infinity,
|
|
1170
|
-
scoreB = Infinity;
|
|
1171
|
-
for (let e = 0; e < allPaths.length; e++) {
|
|
1172
|
-
if (e === ownEdgeIdx) continue;
|
|
1173
|
-
scoreA = Math.min(scoreA, pointToPolylineDist(sideA, allPaths[e]!));
|
|
1174
|
-
scoreB = Math.min(scoreB, pointToPolylineDist(sideB, allPaths[e]!));
|
|
1175
|
-
}
|
|
1176
|
-
for (const placed of placedRects) {
|
|
1177
|
-
if (
|
|
1178
|
-
rectsOverlap(
|
|
1179
|
-
sideA.x,
|
|
1180
|
-
sideA.y,
|
|
1181
|
-
lbl.bgW,
|
|
1182
|
-
lbl.bgH,
|
|
1183
|
-
placed.x,
|
|
1184
|
-
placed.y,
|
|
1185
|
-
placed.w,
|
|
1186
|
-
placed.h,
|
|
1187
|
-
6
|
|
1188
|
-
)
|
|
1189
|
-
) {
|
|
1190
|
-
scoreA -= 200;
|
|
1191
|
-
}
|
|
1192
|
-
if (
|
|
1193
|
-
rectsOverlap(
|
|
1194
|
-
sideB.x,
|
|
1195
|
-
sideB.y,
|
|
1196
|
-
lbl.bgW,
|
|
1197
|
-
lbl.bgH,
|
|
1198
|
-
placed.x,
|
|
1199
|
-
placed.y,
|
|
1200
|
-
placed.w,
|
|
1201
|
-
placed.h,
|
|
1202
|
-
6
|
|
1203
|
-
)
|
|
1204
|
-
) {
|
|
1205
|
-
scoreB -= 200;
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
if (obstacleRects) {
|
|
1209
|
-
for (const obs of obstacleRects) {
|
|
1210
|
-
const cx = obs.x + obs.w / 2,
|
|
1211
|
-
cy = obs.y + obs.h / 2;
|
|
1212
|
-
if (
|
|
1213
|
-
rectsOverlap(
|
|
1214
|
-
sideA.x,
|
|
1215
|
-
sideA.y,
|
|
1216
|
-
lbl.bgW,
|
|
1217
|
-
lbl.bgH,
|
|
1218
|
-
cx,
|
|
1219
|
-
cy,
|
|
1220
|
-
obs.w,
|
|
1221
|
-
obs.h,
|
|
1222
|
-
6
|
|
1223
|
-
)
|
|
1224
|
-
) {
|
|
1225
|
-
scoreA -= 200;
|
|
1226
|
-
}
|
|
1227
|
-
if (
|
|
1228
|
-
rectsOverlap(
|
|
1229
|
-
sideB.x,
|
|
1230
|
-
sideB.y,
|
|
1231
|
-
lbl.bgW,
|
|
1232
|
-
lbl.bgH,
|
|
1233
|
-
cx,
|
|
1234
|
-
cy,
|
|
1235
|
-
obs.w,
|
|
1236
|
-
obs.h,
|
|
1237
|
-
6
|
|
1238
|
-
)
|
|
1239
|
-
) {
|
|
1240
|
-
scoreB -= 200;
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
const finalPt = scoreA >= scoreB ? sideA : sideB;
|
|
1246
|
-
lbl.x = finalPt.x;
|
|
1247
|
-
lbl.y = finalPt.y;
|
|
1248
|
-
} else {
|
|
1249
|
-
lbl.x = bestPt.x;
|
|
1250
|
-
lbl.y = bestPt.y;
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
placedRects.push({ x: lbl.x, y: lbl.y, w: lbl.bgW, h: lbl.bgH });
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
function renderLegend(
|
|
1258
|
-
parent: GSelection,
|
|
1259
|
-
layout: C4LayoutResult,
|
|
1260
|
-
palette: PaletteColors,
|
|
1261
|
-
isDark: boolean,
|
|
1262
|
-
activeTagGroup?: string | null,
|
|
1263
|
-
fixedWidth?: number | null,
|
|
1264
|
-
exportMode?: boolean
|
|
1265
|
-
): void {
|
|
1266
|
-
const groups = layout.legend.map((g) => ({
|
|
1267
|
-
name: g.name,
|
|
1268
|
-
entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
|
|
1269
|
-
}));
|
|
1270
|
-
const containerWidth = fixedWidth ?? layout.width;
|
|
1271
|
-
renderIntegratedLegend(parent, {
|
|
1272
|
-
groups,
|
|
1273
|
-
activeGroup: activeTagGroup ?? null,
|
|
1274
|
-
mode: exportMode ? 'export' : 'preview',
|
|
1275
|
-
palette,
|
|
1276
|
-
isDark,
|
|
1277
|
-
width: containerWidth,
|
|
1278
|
-
});
|
|
1279
|
-
parent.selectAll('[data-legend-group]').classed('c4-legend-group', true);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
// ============================================================
|
|
1283
|
-
// Container-Level Renderer
|
|
1284
|
-
// ============================================================
|
|
1285
|
-
|
|
1286
|
-
/**
|
|
1287
|
-
* Render a C4 container-level diagram showing containers inside a system boundary
|
|
1288
|
-
* with external elements outside.
|
|
1289
|
-
*/
|
|
1290
|
-
export function renderC4Containers(
|
|
1291
|
-
container: HTMLDivElement,
|
|
1292
|
-
parsed: ParsedC4,
|
|
1293
|
-
layout: C4LayoutResult,
|
|
1294
|
-
palette: PaletteColors,
|
|
1295
|
-
isDark: boolean,
|
|
1296
|
-
onClickItem?: (lineNumber: number) => void,
|
|
1297
|
-
exportDims?: { width?: number; height?: number },
|
|
1298
|
-
activeTagGroup?: string | null,
|
|
1299
|
-
exportMode?: boolean
|
|
1300
|
-
): void {
|
|
1301
|
-
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
1302
|
-
|
|
1303
|
-
const width = exportDims?.width ?? container.clientWidth;
|
|
1304
|
-
const height = exportDims?.height ?? container.clientHeight;
|
|
1305
|
-
if (width <= 0 || height <= 0) return;
|
|
1306
|
-
|
|
1307
|
-
const titleHeight =
|
|
1308
|
-
parsed.title && parsed.options['no-title'] !== 'on' ? TITLE_HEIGHT + 10 : 0;
|
|
1309
|
-
const diagramW = layout.width;
|
|
1310
|
-
const hasLegend = layout.legend.length > 0;
|
|
1311
|
-
// In app mode, legend is a fixed overlay outside the scaled group.
|
|
1312
|
-
// C4 layout adds MARGIN(40) + LEGEND_HEIGHT below content — remove that from diagramH.
|
|
1313
|
-
const C4_LAYOUT_MARGIN = 40;
|
|
1314
|
-
const LEGEND_FIXED_GAP = 8;
|
|
1315
|
-
const fixedLegend = !exportDims && hasLegend;
|
|
1316
|
-
const legendLayoutSpace = C4_LAYOUT_MARGIN + LEGEND_HEIGHT;
|
|
1317
|
-
const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
|
|
1318
|
-
const diagramH = fixedLegend
|
|
1319
|
-
? layout.height - legendLayoutSpace
|
|
1320
|
-
: layout.height;
|
|
1321
|
-
const availH = height - titleHeight - legendReserveH;
|
|
1322
|
-
const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
|
|
1323
|
-
const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
|
|
1324
|
-
const scale = Math.min(MAX_SCALE, scaleX, scaleY);
|
|
1325
|
-
|
|
1326
|
-
const scaledW = diagramW * scale;
|
|
1327
|
-
const offsetX = (width - scaledW) / 2;
|
|
1328
|
-
const offsetY = titleHeight + DIAGRAM_PADDING + legendReserveH;
|
|
1329
|
-
|
|
1330
|
-
const svg = d3Selection
|
|
1331
|
-
.select(container)
|
|
1332
|
-
.append('svg')
|
|
1333
|
-
.attr('width', width)
|
|
1334
|
-
.attr('height', height)
|
|
1335
|
-
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
1336
|
-
.attr('preserveAspectRatio', 'xMidYMid meet')
|
|
1337
|
-
.style('font-family', FONT_FAMILY);
|
|
1338
|
-
|
|
1339
|
-
// ── Marker defs ──
|
|
1340
|
-
const defs = svg.append('defs');
|
|
1341
|
-
const AW = 10;
|
|
1342
|
-
const AH = 7;
|
|
1343
|
-
|
|
1344
|
-
defs
|
|
1345
|
-
.append('marker')
|
|
1346
|
-
.attr('id', 'c4-arrow-end')
|
|
1347
|
-
.attr('viewBox', `0 0 ${AW} ${AH}`)
|
|
1348
|
-
.attr('refX', AW)
|
|
1349
|
-
.attr('refY', AH / 2)
|
|
1350
|
-
.attr('markerWidth', AW)
|
|
1351
|
-
.attr('markerHeight', AH)
|
|
1352
|
-
.attr('orient', 'auto')
|
|
1353
|
-
.append('polygon')
|
|
1354
|
-
.attr('points', `0,0 ${AW},${AH / 2} 0,${AH}`)
|
|
1355
|
-
.attr('fill', palette.textMuted);
|
|
1356
|
-
|
|
1357
|
-
defs
|
|
1358
|
-
.append('marker')
|
|
1359
|
-
.attr('id', 'c4-arrow-start')
|
|
1360
|
-
.attr('viewBox', `0 0 ${AW} ${AH}`)
|
|
1361
|
-
.attr('refX', 0)
|
|
1362
|
-
.attr('refY', AH / 2)
|
|
1363
|
-
.attr('markerWidth', AW)
|
|
1364
|
-
.attr('markerHeight', AH)
|
|
1365
|
-
.attr('orient', 'auto')
|
|
1366
|
-
.append('polygon')
|
|
1367
|
-
.attr('points', `${AW},0 0,${AH / 2} ${AW},${AH}`)
|
|
1368
|
-
.attr('fill', palette.textMuted);
|
|
1369
|
-
|
|
1370
|
-
// ── Title ──
|
|
1371
|
-
if (parsed.title && parsed.options['no-title'] !== 'on') {
|
|
1372
|
-
const titleEl = svg
|
|
1373
|
-
.append('text')
|
|
1374
|
-
.attr('class', 'chart-title')
|
|
1375
|
-
.attr('x', width / 2)
|
|
1376
|
-
.attr('y', 30)
|
|
1377
|
-
.attr('text-anchor', 'middle')
|
|
1378
|
-
.attr('fill', palette.text)
|
|
1379
|
-
.attr('font-size', TITLE_FONT_SIZE)
|
|
1380
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
1381
|
-
.style(
|
|
1382
|
-
'cursor',
|
|
1383
|
-
onClickItem && parsed.titleLineNumber ? 'pointer' : 'default'
|
|
1384
|
-
)
|
|
1385
|
-
.text(parsed.title);
|
|
1386
|
-
|
|
1387
|
-
if (parsed.titleLineNumber) {
|
|
1388
|
-
titleEl.attr('data-line-number', parsed.titleLineNumber);
|
|
1389
|
-
if (onClickItem) {
|
|
1390
|
-
titleEl
|
|
1391
|
-
.on('click', () => onClickItem(parsed.titleLineNumber!))
|
|
1392
|
-
.on('mouseenter', function () {
|
|
1393
|
-
d3Selection.select(this).attr('opacity', 0.7);
|
|
1394
|
-
})
|
|
1395
|
-
.on('mouseleave', function () {
|
|
1396
|
-
d3Selection.select(this).attr('opacity', 1);
|
|
1397
|
-
});
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// ── Content group ──
|
|
1403
|
-
const contentG = svg
|
|
1404
|
-
.append('g')
|
|
1405
|
-
.attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
|
|
1406
|
-
|
|
1407
|
-
// ── Boundary box (background layer) ──
|
|
1408
|
-
if (layout.boundary) {
|
|
1409
|
-
const b = layout.boundary;
|
|
1410
|
-
const boundaryFill = mix(palette.surface, palette.bg, 30);
|
|
1411
|
-
const boundaryStroke = mix(palette.textMuted, palette.bg, 50);
|
|
1412
|
-
|
|
1413
|
-
const boundaryG = contentG
|
|
1414
|
-
.append('g')
|
|
1415
|
-
.attr('class', 'c4-boundary')
|
|
1416
|
-
.attr('data-line-number', String(b.lineNumber));
|
|
1417
|
-
|
|
1418
|
-
if (onClickItem) {
|
|
1419
|
-
boundaryG.style('cursor', 'pointer').on('click', () => {
|
|
1420
|
-
onClickItem(b.lineNumber);
|
|
1421
|
-
});
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
boundaryG
|
|
1425
|
-
.append('rect')
|
|
1426
|
-
.attr('x', b.x)
|
|
1427
|
-
.attr('y', b.y)
|
|
1428
|
-
.attr('width', b.width)
|
|
1429
|
-
.attr('height', b.height)
|
|
1430
|
-
.attr('rx', BOUNDARY_RADIUS)
|
|
1431
|
-
.attr('ry', BOUNDARY_RADIUS)
|
|
1432
|
-
.attr('fill', boundaryFill)
|
|
1433
|
-
.attr('stroke', boundaryStroke)
|
|
1434
|
-
.attr('stroke-width', BOUNDARY_STROKE_WIDTH);
|
|
1435
|
-
|
|
1436
|
-
// Boundary label
|
|
1437
|
-
boundaryG
|
|
1438
|
-
.append('text')
|
|
1439
|
-
.attr('x', b.x + 12)
|
|
1440
|
-
.attr('y', b.y + 16)
|
|
1441
|
-
.attr('fill', palette.textMuted)
|
|
1442
|
-
.attr('font-size', BOUNDARY_LABEL_FONT_SIZE)
|
|
1443
|
-
.attr('font-style', 'italic')
|
|
1444
|
-
.text(`${b.label} \u2014 ${b.typeLabel}`);
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
// ── Group boundaries (between parent boundary and edges) ──
|
|
1448
|
-
if (layout.groupBoundaries.length > 0) {
|
|
1449
|
-
const groupFill = mix(palette.surface, palette.bg, 15);
|
|
1450
|
-
const groupStroke = mix(palette.textMuted, palette.bg, 60);
|
|
1451
|
-
|
|
1452
|
-
for (const gb of layout.groupBoundaries) {
|
|
1453
|
-
const gbG = contentG
|
|
1454
|
-
.append('g')
|
|
1455
|
-
.attr('class', 'c4-group-boundary')
|
|
1456
|
-
.attr('data-line-number', String(gb.lineNumber));
|
|
1457
|
-
|
|
1458
|
-
if (onClickItem) {
|
|
1459
|
-
gbG.style('cursor', 'pointer').on('click', () => {
|
|
1460
|
-
onClickItem(gb.lineNumber);
|
|
1461
|
-
});
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
gbG
|
|
1465
|
-
.append('rect')
|
|
1466
|
-
.attr('x', gb.x)
|
|
1467
|
-
.attr('y', gb.y)
|
|
1468
|
-
.attr('width', gb.width)
|
|
1469
|
-
.attr('height', gb.height)
|
|
1470
|
-
.attr('rx', 6)
|
|
1471
|
-
.attr('ry', 6)
|
|
1472
|
-
.attr('fill', groupFill)
|
|
1473
|
-
.attr('stroke', groupStroke)
|
|
1474
|
-
.attr('stroke-width', 1);
|
|
1475
|
-
|
|
1476
|
-
// Group label — top-left, italic, name only
|
|
1477
|
-
gbG
|
|
1478
|
-
.append('text')
|
|
1479
|
-
.attr('x', gb.x + 10)
|
|
1480
|
-
.attr('y', gb.y + 14)
|
|
1481
|
-
.attr('fill', palette.textMuted)
|
|
1482
|
-
.attr('font-size', BOUNDARY_LABEL_FONT_SIZE)
|
|
1483
|
-
.attr('font-style', 'italic')
|
|
1484
|
-
.text(gb.label);
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
// ── Collect boundary label rects as obstacles for edge label placement ──
|
|
1489
|
-
const boundaryLabelObstacles: {
|
|
1490
|
-
x: number;
|
|
1491
|
-
y: number;
|
|
1492
|
-
w: number;
|
|
1493
|
-
h: number;
|
|
1494
|
-
}[] = [];
|
|
1495
|
-
if (layout.boundary) {
|
|
1496
|
-
const b = layout.boundary;
|
|
1497
|
-
const labelText = `${b.label} \u2014 ${b.typeLabel}`;
|
|
1498
|
-
const w = measureText(labelText, BOUNDARY_LABEL_FONT_SIZE) + 12;
|
|
1499
|
-
const h = BOUNDARY_LABEL_FONT_SIZE + 4;
|
|
1500
|
-
boundaryLabelObstacles.push({ x: b.x + 12, y: b.y + 16 - h + 4, w, h });
|
|
1501
|
-
}
|
|
1502
|
-
for (const gb of layout.groupBoundaries) {
|
|
1503
|
-
const w = measureText(gb.label, BOUNDARY_LABEL_FONT_SIZE) + 12;
|
|
1504
|
-
const h = BOUNDARY_LABEL_FONT_SIZE + 4;
|
|
1505
|
-
boundaryLabelObstacles.push({ x: gb.x + 10, y: gb.y + 14 - h + 4, w, h });
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
// ── Edges (behind nodes) ──
|
|
1509
|
-
renderEdges(
|
|
1510
|
-
contentG as GSelection,
|
|
1511
|
-
layout.edges,
|
|
1512
|
-
palette,
|
|
1513
|
-
onClickItem,
|
|
1514
|
-
boundaryLabelObstacles
|
|
1515
|
-
);
|
|
1516
|
-
|
|
1517
|
-
// ── Nodes ──
|
|
1518
|
-
for (const node of layout.nodes) {
|
|
1519
|
-
const nodeG = contentG
|
|
1520
|
-
.append('g')
|
|
1521
|
-
.attr('transform', `translate(${node.x}, ${node.y})`)
|
|
1522
|
-
.attr('class', 'c4-card')
|
|
1523
|
-
.attr('data-line-number', String(node.lineNumber))
|
|
1524
|
-
.attr('data-node-id', node.id);
|
|
1525
|
-
|
|
1526
|
-
if (activeTagGroup) {
|
|
1527
|
-
const tagKey = tagAttrKey(activeTagGroup);
|
|
1528
|
-
const tagValue = node.metadata[tagKey];
|
|
1529
|
-
if (tagValue) {
|
|
1530
|
-
nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
|
|
1531
|
-
} else {
|
|
1532
|
-
// Fall back to the group's defaultValue so hover-dimming works for
|
|
1533
|
-
// nodes that inherit the default (e.g. sc: Internal default).
|
|
1534
|
-
const tagGroup = parsed.tagGroups.find(
|
|
1535
|
-
(g) =>
|
|
1536
|
-
g.name.toLowerCase() === tagKey || g.alias?.toLowerCase() === tagKey
|
|
1537
|
-
);
|
|
1538
|
-
if (tagGroup?.defaultValue) {
|
|
1539
|
-
nodeG.attr(`data-tag-${tagKey}`, tagGroup.defaultValue.toLowerCase());
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
if (node.shape) {
|
|
1545
|
-
nodeG.attr('data-shape', node.shape);
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
if (node.importPath) {
|
|
1549
|
-
nodeG.attr('data-import-path', node.importPath);
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
if (onClickItem) {
|
|
1553
|
-
nodeG.style('cursor', 'pointer').on('click', () => {
|
|
1554
|
-
onClickItem(node.lineNumber);
|
|
1555
|
-
});
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
const w = node.width;
|
|
1559
|
-
const h = node.height;
|
|
1560
|
-
const solid = parsed.options['solid-fill'] === 'on';
|
|
1561
|
-
const fill = nodeFill(palette, isDark, node.type, node.color, solid);
|
|
1562
|
-
const stroke = nodeStroke(palette, node.type, node.color);
|
|
1563
|
-
const onFillText = contrastText(
|
|
1564
|
-
fill,
|
|
1565
|
-
palette.textOnFillLight,
|
|
1566
|
-
palette.textOnFillDark
|
|
1567
|
-
);
|
|
1568
|
-
const shape = node.shape ?? 'default';
|
|
1569
|
-
const isExternalShape = shape === 'external';
|
|
1570
|
-
|
|
1571
|
-
// Card background — shape-specific
|
|
1572
|
-
if (shape === 'database' || shape === 'cache') {
|
|
1573
|
-
drawCylinderCard(
|
|
1574
|
-
nodeG as GSelection,
|
|
1575
|
-
w,
|
|
1576
|
-
h,
|
|
1577
|
-
fill,
|
|
1578
|
-
stroke,
|
|
1579
|
-
shape === 'cache'
|
|
1580
|
-
);
|
|
1581
|
-
} else {
|
|
1582
|
-
drawCardRect(nodeG as GSelection, w, h, fill, stroke, isExternalShape);
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
let yPos = -h / 2 + CARD_V_PAD;
|
|
1586
|
-
|
|
1587
|
-
// For cylinder shapes, offset content down past the top ellipse
|
|
1588
|
-
if (shape === 'database' || shape === 'cache') {
|
|
1589
|
-
yPos += CYLINDER_RY;
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
// Type label — only for external elements (person/system); containers/components are the default
|
|
1593
|
-
if (node.type !== 'container' && node.type !== 'component') {
|
|
1594
|
-
const typeLabel = `\u00AB${node.type}\u00BB`;
|
|
1595
|
-
nodeG
|
|
1596
|
-
.append('text')
|
|
1597
|
-
.attr('x', 0)
|
|
1598
|
-
.attr('y', yPos + TYPE_FONT_SIZE / 2)
|
|
1599
|
-
.attr('text-anchor', 'middle')
|
|
1600
|
-
.attr('dominant-baseline', 'central')
|
|
1601
|
-
.attr('fill', onFillText)
|
|
1602
|
-
.attr('font-size', TYPE_FONT_SIZE)
|
|
1603
|
-
.attr('font-style', 'italic')
|
|
1604
|
-
.text(typeLabel);
|
|
1605
|
-
|
|
1606
|
-
yPos += TYPE_LABEL_HEIGHT;
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
// Name (bold)
|
|
1610
|
-
if (node.type === 'person') {
|
|
1611
|
-
const textWidth = measureText(node.name, NAME_FONT_SIZE);
|
|
1612
|
-
const gap = 6;
|
|
1613
|
-
const totalWidth = PERSON_ICON_W + gap + textWidth;
|
|
1614
|
-
const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
|
|
1615
|
-
const textX = iconCx + PERSON_ICON_W / 2 + gap;
|
|
1616
|
-
|
|
1617
|
-
drawPersonIcon(
|
|
1618
|
-
nodeG as GSelection,
|
|
1619
|
-
iconCx,
|
|
1620
|
-
yPos + NAME_FONT_SIZE / 2 - 2,
|
|
1621
|
-
stroke
|
|
1622
|
-
);
|
|
1623
|
-
|
|
1624
|
-
nodeG
|
|
1625
|
-
.append('text')
|
|
1626
|
-
.attr('x', textX)
|
|
1627
|
-
.attr('y', yPos + NAME_FONT_SIZE / 2)
|
|
1628
|
-
.attr('text-anchor', 'start')
|
|
1629
|
-
.attr('dominant-baseline', 'central')
|
|
1630
|
-
.attr('fill', onFillText)
|
|
1631
|
-
.attr('font-size', NAME_FONT_SIZE)
|
|
1632
|
-
.attr('font-weight', 'bold')
|
|
1633
|
-
.text(node.name);
|
|
1634
|
-
} else {
|
|
1635
|
-
nodeG
|
|
1636
|
-
.append('text')
|
|
1637
|
-
.attr('x', 0)
|
|
1638
|
-
.attr('y', yPos + NAME_FONT_SIZE / 2)
|
|
1639
|
-
.attr('text-anchor', 'middle')
|
|
1640
|
-
.attr('dominant-baseline', 'central')
|
|
1641
|
-
.attr('fill', onFillText)
|
|
1642
|
-
.attr('font-size', NAME_FONT_SIZE)
|
|
1643
|
-
.attr('font-weight', 'bold')
|
|
1644
|
-
.text(node.name);
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
yPos += NAME_HEIGHT;
|
|
1648
|
-
|
|
1649
|
-
if (node.type === 'container') {
|
|
1650
|
-
// Container cards: description above divider, metadata below
|
|
1651
|
-
|
|
1652
|
-
// Description (above divider, inline markdown) — contrast against fill
|
|
1653
|
-
if (node.description) {
|
|
1654
|
-
const contentWidth = w - CARD_H_PAD * 2;
|
|
1655
|
-
const lines = wrapTextToWidth(
|
|
1656
|
-
node.description,
|
|
1657
|
-
DESC_FONT_SIZE,
|
|
1658
|
-
contentWidth
|
|
1659
|
-
);
|
|
1660
|
-
for (const line of lines) {
|
|
1661
|
-
const textEl = nodeG
|
|
1662
|
-
.append('text')
|
|
1663
|
-
.attr('x', 0)
|
|
1664
|
-
.attr('y', yPos + DESC_FONT_SIZE / 2)
|
|
1665
|
-
.attr('text-anchor', 'middle')
|
|
1666
|
-
.attr('dominant-baseline', 'central')
|
|
1667
|
-
.attr('fill', onFillText)
|
|
1668
|
-
.attr('font-size', DESC_FONT_SIZE);
|
|
1669
|
-
renderInlineText(
|
|
1670
|
-
textEl,
|
|
1671
|
-
preprocessDescriptionLine(line),
|
|
1672
|
-
palette,
|
|
1673
|
-
DESC_FONT_SIZE
|
|
1674
|
-
);
|
|
1675
|
-
yPos += DESC_LINE_HEIGHT;
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
// Metadata rows below divider (org-chart style: "Key: Value")
|
|
1680
|
-
const metaEntries = collectCardMetadata(node.metadata);
|
|
1681
|
-
if (metaEntries.length > 0) {
|
|
1682
|
-
// Divider
|
|
1683
|
-
nodeG
|
|
1684
|
-
.append('line')
|
|
1685
|
-
.attr('x1', -w / 2 + CARD_H_PAD / 2)
|
|
1686
|
-
.attr('y1', yPos)
|
|
1687
|
-
.attr('x2', w / 2 - CARD_H_PAD / 2)
|
|
1688
|
-
.attr('y2', yPos)
|
|
1689
|
-
.attr('stroke', solid ? onFillText : stroke)
|
|
1690
|
-
.attr('stroke-width', 0.5)
|
|
1691
|
-
.attr('stroke-opacity', 0.4);
|
|
1692
|
-
|
|
1693
|
-
yPos += DIVIDER_GAP;
|
|
1694
|
-
|
|
1695
|
-
const maxKeyWidth = Math.max(
|
|
1696
|
-
...metaEntries.map((e) => measureText(`${e.key}: `, META_FONT_SIZE))
|
|
1697
|
-
);
|
|
1698
|
-
const valueX = -w / 2 + CARD_H_PAD + maxKeyWidth;
|
|
1699
|
-
|
|
1700
|
-
for (const entry of metaEntries) {
|
|
1701
|
-
// Key — contrast against fill (textMuted is illegible on solid fills)
|
|
1702
|
-
nodeG
|
|
1703
|
-
.append('text')
|
|
1704
|
-
.attr('x', -w / 2 + CARD_H_PAD)
|
|
1705
|
-
.attr('y', yPos + META_FONT_SIZE / 2)
|
|
1706
|
-
.attr('text-anchor', 'start')
|
|
1707
|
-
.attr('dominant-baseline', 'central')
|
|
1708
|
-
.attr('fill', onFillText)
|
|
1709
|
-
.attr('font-size', META_FONT_SIZE)
|
|
1710
|
-
.text(`${entry.key}:`);
|
|
1711
|
-
|
|
1712
|
-
// Value (normal) — contrast against card fill
|
|
1713
|
-
nodeG
|
|
1714
|
-
.append('text')
|
|
1715
|
-
.attr('x', valueX)
|
|
1716
|
-
.attr('y', yPos + META_FONT_SIZE / 2)
|
|
1717
|
-
.attr('text-anchor', 'start')
|
|
1718
|
-
.attr('dominant-baseline', 'central')
|
|
1719
|
-
.attr('fill', onFillText)
|
|
1720
|
-
.attr('font-size', META_FONT_SIZE)
|
|
1721
|
-
.text(entry.value);
|
|
1722
|
-
|
|
1723
|
-
yPos += META_LINE_HEIGHT;
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
} else {
|
|
1727
|
-
// External cards (person/system): same as context — divider then description
|
|
1728
|
-
|
|
1729
|
-
// Divider
|
|
1730
|
-
nodeG
|
|
1731
|
-
.append('line')
|
|
1732
|
-
.attr('x1', -w / 2 + CARD_H_PAD / 2)
|
|
1733
|
-
.attr('y1', yPos)
|
|
1734
|
-
.attr('x2', w / 2 - CARD_H_PAD / 2)
|
|
1735
|
-
.attr('y2', yPos)
|
|
1736
|
-
.attr('stroke', solid ? onFillText : stroke)
|
|
1737
|
-
.attr('stroke-width', 0.5)
|
|
1738
|
-
.attr('stroke-opacity', 0.4);
|
|
1739
|
-
|
|
1740
|
-
yPos += DIVIDER_GAP;
|
|
1741
|
-
|
|
1742
|
-
// Description (inline markdown) — contrast against fill
|
|
1743
|
-
if (node.description) {
|
|
1744
|
-
const contentWidth = w - CARD_H_PAD * 2;
|
|
1745
|
-
const lines = wrapTextToWidth(
|
|
1746
|
-
node.description,
|
|
1747
|
-
DESC_FONT_SIZE,
|
|
1748
|
-
contentWidth
|
|
1749
|
-
);
|
|
1750
|
-
for (const line of lines) {
|
|
1751
|
-
const textEl = nodeG
|
|
1752
|
-
.append('text')
|
|
1753
|
-
.attr('x', 0)
|
|
1754
|
-
.attr('y', yPos + DESC_FONT_SIZE / 2)
|
|
1755
|
-
.attr('text-anchor', 'middle')
|
|
1756
|
-
.attr('dominant-baseline', 'central')
|
|
1757
|
-
.attr('fill', onFillText)
|
|
1758
|
-
.attr('font-size', DESC_FONT_SIZE);
|
|
1759
|
-
renderInlineText(
|
|
1760
|
-
textEl,
|
|
1761
|
-
preprocessDescriptionLine(line),
|
|
1762
|
-
palette,
|
|
1763
|
-
DESC_FONT_SIZE
|
|
1764
|
-
);
|
|
1765
|
-
yPos += DESC_LINE_HEIGHT;
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
// Drillable accent bar — solid bar at bottom of card, clipped to rounded corners
|
|
1771
|
-
if (node.drillable) {
|
|
1772
|
-
const clipId = `clip-drill-${node.id.replace(/\s+/g, '-')}`;
|
|
1773
|
-
nodeG
|
|
1774
|
-
.append('clipPath')
|
|
1775
|
-
.attr('id', clipId)
|
|
1776
|
-
.append('rect')
|
|
1777
|
-
.attr('x', -w / 2)
|
|
1778
|
-
.attr('y', -h / 2)
|
|
1779
|
-
.attr('width', w)
|
|
1780
|
-
.attr('height', h)
|
|
1781
|
-
.attr('rx', CARD_RADIUS);
|
|
1782
|
-
nodeG
|
|
1783
|
-
.append('rect')
|
|
1784
|
-
.attr('x', -w / 2)
|
|
1785
|
-
.attr('y', h / 2 - DRILL_BAR_HEIGHT)
|
|
1786
|
-
.attr('width', w)
|
|
1787
|
-
.attr('height', DRILL_BAR_HEIGHT)
|
|
1788
|
-
// In solid mode, `stroke` matches the fill — drill-bar disappears.
|
|
1789
|
-
// Use the contrast text color so the indicator stays visible.
|
|
1790
|
-
.attr('fill', solid ? onFillText : stroke)
|
|
1791
|
-
.attr('clip-path', `url(#${clipId})`)
|
|
1792
|
-
.attr('class', 'c4-drill-bar');
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
// ── Legend ──
|
|
1797
|
-
if (hasLegend) {
|
|
1798
|
-
// App mode: fixed overlay at SVG top so it's always readable regardless of scale.
|
|
1799
|
-
// Export mode: render inside scaled contentG at layout coordinates.
|
|
1800
|
-
const legendParent = fixedLegend
|
|
1801
|
-
? svg
|
|
1802
|
-
.append('g')
|
|
1803
|
-
.attr('class', 'c4-legend-fixed')
|
|
1804
|
-
.attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
|
|
1805
|
-
: contentG.append('g').attr('class', 'c4-legend');
|
|
1806
|
-
if (activeTagGroup) {
|
|
1807
|
-
legendParent.attr('data-legend-active', tagAttrKey(activeTagGroup));
|
|
1808
|
-
}
|
|
1809
|
-
renderLegend(
|
|
1810
|
-
legendParent as GSelection,
|
|
1811
|
-
layout,
|
|
1812
|
-
palette,
|
|
1813
|
-
isDark,
|
|
1814
|
-
activeTagGroup,
|
|
1815
|
-
fixedLegend ? width : null,
|
|
1816
|
-
exportMode
|
|
1817
|
-
);
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
// ============================================================
|
|
1822
|
-
// Container Export convenience function
|
|
1823
|
-
// ============================================================
|
|
1824
|
-
|
|
1825
|
-
export function renderC4ContainersForExport(
|
|
1826
|
-
content: string,
|
|
1827
|
-
systemName: string,
|
|
1828
|
-
theme: 'light' | 'dark' | 'transparent',
|
|
1829
|
-
palette: PaletteColors
|
|
1830
|
-
): string {
|
|
1831
|
-
const parsed = parseC4(content, palette);
|
|
1832
|
-
if (parsed.error || parsed.elements.length === 0) return '';
|
|
1833
|
-
|
|
1834
|
-
const layout = layoutC4Containers(parsed, systemName);
|
|
1835
|
-
if (layout.nodes.length === 0) return '';
|
|
1836
|
-
|
|
1837
|
-
const isDark = theme === 'dark';
|
|
1838
|
-
|
|
1839
|
-
const el = document.createElement('div');
|
|
1840
|
-
const titleOffset =
|
|
1841
|
-
parsed.title && parsed.options['no-title'] !== 'on' ? TITLE_HEIGHT + 10 : 0;
|
|
1842
|
-
const exportWidth = layout.width + DIAGRAM_PADDING * 2;
|
|
1843
|
-
const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
|
|
1844
|
-
|
|
1845
|
-
el.style.width = `${exportWidth}px`;
|
|
1846
|
-
el.style.height = `${exportHeight}px`;
|
|
1847
|
-
el.style.position = 'absolute';
|
|
1848
|
-
el.style.left = '-9999px';
|
|
1849
|
-
document.body.appendChild(el);
|
|
1850
|
-
|
|
1851
|
-
try {
|
|
1852
|
-
renderC4Containers(el, parsed, layout, palette, isDark, undefined, {
|
|
1853
|
-
width: exportWidth,
|
|
1854
|
-
height: exportHeight,
|
|
1855
|
-
});
|
|
1856
|
-
|
|
1857
|
-
const svgEl = el.querySelector('svg');
|
|
1858
|
-
if (!svgEl) return '';
|
|
1859
|
-
|
|
1860
|
-
if (theme === 'transparent') {
|
|
1861
|
-
svgEl.style.background = 'none';
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
1865
|
-
svgEl.style.fontFamily = FONT_FAMILY;
|
|
1866
|
-
|
|
1867
|
-
return svgEl.outerHTML;
|
|
1868
|
-
} finally {
|
|
1869
|
-
document.body.removeChild(el);
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
// ============================================================
|
|
1874
|
-
// Component Export convenience function
|
|
1875
|
-
// ============================================================
|
|
1876
|
-
|
|
1877
|
-
export function renderC4ComponentsForExport(
|
|
1878
|
-
content: string,
|
|
1879
|
-
systemName: string,
|
|
1880
|
-
containerName: string,
|
|
1881
|
-
theme: 'light' | 'dark' | 'transparent',
|
|
1882
|
-
palette: PaletteColors
|
|
1883
|
-
): string {
|
|
1884
|
-
const parsed = parseC4(content, palette);
|
|
1885
|
-
if (parsed.error || parsed.elements.length === 0) return '';
|
|
1886
|
-
|
|
1887
|
-
const layout = layoutC4Components(parsed, systemName, containerName);
|
|
1888
|
-
if (layout.nodes.length === 0) return '';
|
|
1889
|
-
|
|
1890
|
-
const isDark = theme === 'dark';
|
|
1891
|
-
|
|
1892
|
-
const el = document.createElement('div');
|
|
1893
|
-
const titleOffset =
|
|
1894
|
-
parsed.title && parsed.options['no-title'] !== 'on' ? TITLE_HEIGHT + 10 : 0;
|
|
1895
|
-
const exportWidth = layout.width + DIAGRAM_PADDING * 2;
|
|
1896
|
-
const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
|
|
1897
|
-
|
|
1898
|
-
el.style.width = `${exportWidth}px`;
|
|
1899
|
-
el.style.height = `${exportHeight}px`;
|
|
1900
|
-
el.style.position = 'absolute';
|
|
1901
|
-
el.style.left = '-9999px';
|
|
1902
|
-
document.body.appendChild(el);
|
|
1903
|
-
|
|
1904
|
-
try {
|
|
1905
|
-
// Reuse the container renderer — it handles all node types generically
|
|
1906
|
-
renderC4Containers(el, parsed, layout, palette, isDark, undefined, {
|
|
1907
|
-
width: exportWidth,
|
|
1908
|
-
height: exportHeight,
|
|
1909
|
-
});
|
|
1910
|
-
|
|
1911
|
-
const svgEl = el.querySelector('svg');
|
|
1912
|
-
if (!svgEl) return '';
|
|
1913
|
-
|
|
1914
|
-
if (theme === 'transparent') {
|
|
1915
|
-
svgEl.style.background = 'none';
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
1919
|
-
svgEl.style.fontFamily = FONT_FAMILY;
|
|
1920
|
-
|
|
1921
|
-
return svgEl.outerHTML;
|
|
1922
|
-
} finally {
|
|
1923
|
-
document.body.removeChild(el);
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
// ============================================================
|
|
1928
|
-
// Deployment Diagram Renderer
|
|
1929
|
-
// ============================================================
|
|
1930
|
-
|
|
1931
|
-
/**
|
|
1932
|
-
* Render a C4 deployment diagram interactively.
|
|
1933
|
-
* Reuses the container renderer — infrastructure boundaries are rendered
|
|
1934
|
-
* as group boundaries and container refs as cards (same visual pattern).
|
|
1935
|
-
*/
|
|
1936
|
-
export function renderC4Deployment(
|
|
1937
|
-
container: HTMLDivElement,
|
|
1938
|
-
parsed: ParsedC4,
|
|
1939
|
-
layout: C4LayoutResult,
|
|
1940
|
-
palette: PaletteColors,
|
|
1941
|
-
isDark: boolean,
|
|
1942
|
-
onClickItem?: (lineNumber: number) => void,
|
|
1943
|
-
exportDims?: { width?: number; height?: number },
|
|
1944
|
-
activeTagGroup?: string | null,
|
|
1945
|
-
exportMode?: boolean
|
|
1946
|
-
): void {
|
|
1947
|
-
renderC4Containers(
|
|
1948
|
-
container,
|
|
1949
|
-
parsed,
|
|
1950
|
-
layout,
|
|
1951
|
-
palette,
|
|
1952
|
-
isDark,
|
|
1953
|
-
onClickItem,
|
|
1954
|
-
exportDims,
|
|
1955
|
-
activeTagGroup,
|
|
1956
|
-
exportMode
|
|
1957
|
-
);
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
/**
|
|
1961
|
-
* Export convenience function for deployment diagrams.
|
|
1962
|
-
*/
|
|
1963
|
-
export function renderC4DeploymentForExport(
|
|
1964
|
-
content: string,
|
|
1965
|
-
theme: 'light' | 'dark' | 'transparent',
|
|
1966
|
-
palette: PaletteColors
|
|
1967
|
-
): string {
|
|
1968
|
-
const parsed = parseC4(content, palette);
|
|
1969
|
-
if (parsed.error || parsed.deployment.length === 0) return '';
|
|
1970
|
-
|
|
1971
|
-
const layout = layoutC4Deployment(parsed);
|
|
1972
|
-
if (layout.nodes.length === 0) return '';
|
|
1973
|
-
|
|
1974
|
-
const isDark = theme === 'dark';
|
|
1975
|
-
|
|
1976
|
-
const el = document.createElement('div');
|
|
1977
|
-
const titleOffset =
|
|
1978
|
-
parsed.title && parsed.options['no-title'] !== 'on' ? TITLE_HEIGHT + 10 : 0;
|
|
1979
|
-
const exportWidth = layout.width + DIAGRAM_PADDING * 2;
|
|
1980
|
-
const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
|
|
1981
|
-
|
|
1982
|
-
el.style.width = `${exportWidth}px`;
|
|
1983
|
-
el.style.height = `${exportHeight}px`;
|
|
1984
|
-
el.style.position = 'absolute';
|
|
1985
|
-
el.style.left = '-9999px';
|
|
1986
|
-
document.body.appendChild(el);
|
|
1987
|
-
|
|
1988
|
-
try {
|
|
1989
|
-
renderC4Containers(el, parsed, layout, palette, isDark, undefined, {
|
|
1990
|
-
width: exportWidth,
|
|
1991
|
-
height: exportHeight,
|
|
1992
|
-
});
|
|
1993
|
-
|
|
1994
|
-
const svgEl = el.querySelector('svg');
|
|
1995
|
-
if (!svgEl) return '';
|
|
1996
|
-
|
|
1997
|
-
if (theme === 'transparent') {
|
|
1998
|
-
svgEl.style.background = 'none';
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
2002
|
-
svgEl.style.fontFamily = FONT_FAMILY;
|
|
2003
|
-
|
|
2004
|
-
return svgEl.outerHTML;
|
|
2005
|
-
} finally {
|
|
2006
|
-
document.body.removeChild(el);
|
|
2007
|
-
}
|
|
2008
|
-
}
|