@diagrammo/dgmo 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/dgmo.md +76 -0
- package/dist/cli.cjs +164 -162
- package/dist/index.cjs +1146 -647
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -21
- package/dist/index.d.ts +9 -21
- package/dist/index.js +1146 -647
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +33 -50
- package/package.json +4 -3
- package/src/c4/layout.ts +75 -72
- package/src/c4/renderer.ts +122 -119
- package/src/cli.ts +130 -40
- package/src/d3.ts +55 -35
- package/src/echarts.ts +24 -24
- package/src/er/classify.ts +206 -0
- package/src/er/layout.ts +259 -94
- package/src/er/renderer.ts +246 -26
- package/src/index.ts +2 -2
- package/src/infra/compute.ts +1 -21
- package/src/infra/layout.ts +60 -13
- package/src/infra/parser.ts +5 -32
- package/src/infra/renderer.ts +403 -196
- package/src/infra/types.ts +1 -11
- package/src/initiative-status/layout.ts +46 -27
- package/src/kanban/renderer.ts +28 -24
- package/src/org/renderer.ts +24 -23
- package/src/render.ts +2 -2
- package/src/sequence/renderer.ts +24 -19
- package/src/sitemap/layout.ts +7 -14
- package/src/sitemap/renderer.ts +30 -29
- package/src/utils/legend-constants.ts +25 -0
- package/.claude/skills/dgmo-chart/SKILL.md +0 -141
- package/.claude/skills/dgmo-flowchart/SKILL.md +0 -61
- package/.claude/skills/dgmo-generate/SKILL.md +0 -59
- package/.claude/skills/dgmo-sequence/SKILL.md +0 -104
package/src/infra/types.ts
CHANGED
|
@@ -99,13 +99,6 @@ export interface InfraTagGroup {
|
|
|
99
99
|
lineNumber: number;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export interface InfraScenario {
|
|
103
|
-
name: string;
|
|
104
|
-
/** Node property overrides: nodeId -> { key: value } */
|
|
105
|
-
overrides: Record<string, Record<string, string | number>>;
|
|
106
|
-
lineNumber: number;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
102
|
export interface ParsedInfra {
|
|
110
103
|
type: 'infra';
|
|
111
104
|
title: string | null;
|
|
@@ -115,7 +108,6 @@ export interface ParsedInfra {
|
|
|
115
108
|
edges: InfraEdge[];
|
|
116
109
|
groups: InfraGroup[];
|
|
117
110
|
tagGroups: InfraTagGroup[];
|
|
118
|
-
scenarios: InfraScenario[];
|
|
119
111
|
options: Record<string, string>;
|
|
120
112
|
diagnostics: DgmoError[];
|
|
121
113
|
error: string | null;
|
|
@@ -128,9 +120,7 @@ export interface ParsedInfra {
|
|
|
128
120
|
export interface InfraComputeParams {
|
|
129
121
|
rps?: number; // override edge rps (for slider)
|
|
130
122
|
instanceOverrides?: Record<string, number>; // nodeId -> instance count override
|
|
131
|
-
|
|
132
|
-
/** Per-node property overrides: nodeId -> { propertyKey: numericValue }.
|
|
133
|
-
* Applied after scenario overrides. Lets sliders adjust cache-hit, etc. */
|
|
123
|
+
/** Per-node property overrides: nodeId -> { propertyKey: numericValue }. */
|
|
134
124
|
propertyOverrides?: Record<string, Record<string, number>>;
|
|
135
125
|
/** Set of group IDs that should be treated as collapsed (virtual nodes). */
|
|
136
126
|
collapsedGroups?: Set<string>;
|
|
@@ -28,8 +28,8 @@ export interface ISLayoutEdge {
|
|
|
28
28
|
status: import('./types').InitiativeStatus;
|
|
29
29
|
lineNumber: number;
|
|
30
30
|
// Layout contract for points[]:
|
|
31
|
-
// Back-edges:
|
|
32
|
-
//
|
|
31
|
+
// Back-edges: 5 points — [src.top/bottom_center, depart_ctrl, arc_control, approach_ctrl, tgt.top/bottom_center]
|
|
32
|
+
// Top/bottom-exit: 4 points — [src.top/bottom_center, depart_ctrl, tgt_approach, tgt.left_center]
|
|
33
33
|
// 4-point elbow: points[0] and points[last] pinned at node center Y; interior fans via yOffset
|
|
34
34
|
// fixedDagrePoints: points[0]=src.right, points[last]=tgt.left; interior from dagre
|
|
35
35
|
points: { x: number; y: number }[];
|
|
@@ -81,6 +81,7 @@ const PARALLEL_EDGE_MARGIN = 12; // total vertical margin reserved at top+bottom
|
|
|
81
81
|
const MAX_PARALLEL_EDGES = 5; // at most this many edges rendered between any directed source→target pair
|
|
82
82
|
const BACK_EDGE_MARGIN = 40; // clearance below/above nodes for back-edge arcs (~half NODESEP)
|
|
83
83
|
const BACK_EDGE_MIN_SPREAD = Math.round(NODE_WIDTH * 0.75); // minimum horizontal arc spread for near-same-X back-edges
|
|
84
|
+
const TOP_EXIT_STEP = 10; // px: control-point offset giving near-vertical departure tangent for top/bottom-exit elbows
|
|
84
85
|
const CHAR_WIDTH_RATIO = 0.6;
|
|
85
86
|
const NODE_FONT_SIZE = 13;
|
|
86
87
|
const NODE_TEXT_PADDING = 12;
|
|
@@ -221,15 +222,14 @@ export function layoutInitiativeStatus(
|
|
|
221
222
|
const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
|
|
222
223
|
const dagrePoints: { x: number; y: number }[] = dagreEdge?.points ?? [];
|
|
223
224
|
const hasIntermediateRank = allNodeX.some((x) => x > src.x + 20 && x < tgt.x - 20);
|
|
224
|
-
const step = Math.min((enterX - exitX) * 0.15, 20);
|
|
225
|
+
const step = Math.max(0, Math.min((enterX - exitX) * 0.15, 20)); // clamped ≥0: guards overlapping nodes
|
|
225
226
|
|
|
226
|
-
//
|
|
227
|
-
const isBackEdge
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// original use case (fan targets far below source in the same adjacent rank).
|
|
227
|
+
// 5-branch routing: isBackEdge → isTopExit → isBottomExit → 4-point elbow → fixedDagrePoints
|
|
228
|
+
const isBackEdge = tgt.x < src.x - 5; // 5px epsilon: same-rank same-X nodes must not false-match
|
|
229
|
+
// Guards: tgt.x > src.x (strict) keeps step positive; !hasIntermediateRank defers multi-rank
|
|
230
|
+
// displaced edges to fixedDagrePoints so dagre can route around intermediate nodes.
|
|
231
|
+
const isTopExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y < src.y - NODESEP;
|
|
232
|
+
const isBottomExit = !isBackEdge && tgt.x > src.x && !hasIntermediateRank && tgt.y > src.y + NODESEP;
|
|
233
233
|
|
|
234
234
|
let points: { x: number; y: number }[];
|
|
235
235
|
|
|
@@ -248,33 +248,52 @@ export function layoutInitiativeStatus(
|
|
|
248
248
|
? rawMidX + spreadDir * BACK_EDGE_MIN_SPREAD
|
|
249
249
|
: rawMidX;
|
|
250
250
|
const midX = Math.min(src.x, Math.max(tgt.x, unclamped));
|
|
251
|
+
// Clamped departure/approach control points give near-orthogonal tangents at node edges.
|
|
252
|
+
// For narrow back-edges (|src.x - tgt.x| < 2*TOP_EXIT_STEP), clamps degrade to midX±1 — valid.
|
|
253
|
+
const srcDepart = Math.max(midX + 1, src.x - TOP_EXIT_STEP);
|
|
254
|
+
const tgtApproach = Math.min(midX - 1, tgt.x + TOP_EXIT_STEP);
|
|
251
255
|
if (routeAbove) {
|
|
252
256
|
const arcY = Math.min(src.y - srcHalfH, tgt.y - tgtHalfH) - BACK_EDGE_MARGIN;
|
|
253
257
|
points = [
|
|
254
|
-
{ x: src.x,
|
|
255
|
-
{ x:
|
|
256
|
-
{ x:
|
|
258
|
+
{ x: src.x, y: src.y - srcHalfH },
|
|
259
|
+
{ x: srcDepart, y: src.y - srcHalfH - TOP_EXIT_STEP },
|
|
260
|
+
{ x: midX, y: arcY },
|
|
261
|
+
{ x: tgtApproach, y: tgt.y - tgtHalfH - TOP_EXIT_STEP },
|
|
262
|
+
{ x: tgt.x, y: tgt.y - tgtHalfH },
|
|
257
263
|
];
|
|
258
264
|
} else {
|
|
259
265
|
const arcY = Math.max(src.y + srcHalfH, tgt.y + tgtHalfH) + BACK_EDGE_MARGIN;
|
|
260
266
|
points = [
|
|
261
|
-
{ x: src.x,
|
|
262
|
-
{ x:
|
|
263
|
-
{ x:
|
|
267
|
+
{ x: src.x, y: src.y + srcHalfH },
|
|
268
|
+
{ x: srcDepart, y: src.y + srcHalfH + TOP_EXIT_STEP },
|
|
269
|
+
{ x: midX, y: arcY },
|
|
270
|
+
{ x: tgtApproach, y: tgt.y + tgtHalfH + TOP_EXIT_STEP },
|
|
271
|
+
{ x: tgt.x, y: tgt.y + tgtHalfH },
|
|
264
272
|
];
|
|
265
273
|
}
|
|
266
|
-
} else if (
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
+
} else if (isTopExit) {
|
|
275
|
+
// 4-point top-exit elbow: exits top of source ~vertically, arrives left of target horizontally.
|
|
276
|
+
// Top exit keeps this edge ABOVE the horizontal right-exit bundle → avoids crossings.
|
|
277
|
+
// yOffset repurposed as X-spread for top/bottom-exit branches (same magnitude, different axis).
|
|
278
|
+
// p1x: floor at src.x prevents negative-yOffset edges from going left of origin (breaks monotone X);
|
|
279
|
+
// ceiling at midpoint-1 prevents overshooting for large positive yOffset (±32px for 5 parallel edges).
|
|
280
|
+
const exitY = src.y - src.height / 2;
|
|
281
|
+
const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
|
|
274
282
|
points = [
|
|
275
|
-
{ x: src.x,
|
|
276
|
-
{ x:
|
|
277
|
-
{ x: enterX, y: tgt.y },
|
|
283
|
+
{ x: src.x, y: exitY },
|
|
284
|
+
{ x: p1x, y: exitY - TOP_EXIT_STEP },
|
|
285
|
+
{ x: enterX - step, y: tgt.y + yOffset },
|
|
286
|
+
{ x: enterX, y: tgt.y },
|
|
287
|
+
];
|
|
288
|
+
} else if (isBottomExit) {
|
|
289
|
+
// 4-point bottom-exit elbow: mirror of top-exit. Keeps edge BELOW the horizontal bundle.
|
|
290
|
+
const exitY = src.y + src.height / 2;
|
|
291
|
+
const p1x = Math.min(Math.max(src.x, src.x + yOffset + TOP_EXIT_STEP), (src.x + enterX) / 2 - 1);
|
|
292
|
+
points = [
|
|
293
|
+
{ x: src.x, y: exitY },
|
|
294
|
+
{ x: p1x, y: exitY + TOP_EXIT_STEP },
|
|
295
|
+
{ x: enterX - step, y: tgt.y + yOffset },
|
|
296
|
+
{ x: enterX, y: tgt.y },
|
|
278
297
|
];
|
|
279
298
|
} else if (tgt.x > src.x && !hasIntermediateRank) {
|
|
280
299
|
// 4-point elbow: adjacent-rank forward edges (unchanged)
|
package/src/kanban/renderer.ts
CHANGED
|
@@ -10,6 +10,13 @@ import { renderInlineText } from '../utils/inline-markdown';
|
|
|
10
10
|
import type { ParsedKanban, KanbanColumn, KanbanCard, KanbanTagGroup } from './types';
|
|
11
11
|
import { parseKanban } from './parser';
|
|
12
12
|
import { isArchiveColumn } from './mutations';
|
|
13
|
+
import {
|
|
14
|
+
LEGEND_HEIGHT,
|
|
15
|
+
LEGEND_PILL_FONT_SIZE,
|
|
16
|
+
LEGEND_DOT_R,
|
|
17
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
18
|
+
LEGEND_CAPSULE_PAD,
|
|
19
|
+
} from '../utils/legend-constants';
|
|
13
20
|
|
|
14
21
|
// ============================================================
|
|
15
22
|
// Constants
|
|
@@ -36,10 +43,6 @@ const CARD_META_FONT_SIZE = 10;
|
|
|
36
43
|
const WIP_FONT_SIZE = 10;
|
|
37
44
|
const COLUMN_RADIUS = 8;
|
|
38
45
|
const COLUMN_HEADER_RADIUS = 8;
|
|
39
|
-
const LEGEND_HEIGHT = 28;
|
|
40
|
-
const LEGEND_FONT_SIZE = 11;
|
|
41
|
-
const LEGEND_DOT_R = 4;
|
|
42
|
-
const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
43
46
|
|
|
44
47
|
// ============================================================
|
|
45
48
|
// Tag color resolution
|
|
@@ -106,9 +109,8 @@ function computeLayout(
|
|
|
106
109
|
parsed: ParsedKanban,
|
|
107
110
|
_palette: PaletteColors
|
|
108
111
|
): { columns: ColumnLayout[]; totalWidth: number; totalHeight: number } {
|
|
109
|
-
// Title
|
|
110
|
-
const
|
|
111
|
-
const headerHeight = hasHeader ? Math.max(TITLE_HEIGHT, LEGEND_HEIGHT) + 8 : 0;
|
|
112
|
+
// Title row
|
|
113
|
+
const headerHeight = parsed.title ? TITLE_HEIGHT + 8 : 0;
|
|
112
114
|
const startY = DIAGRAM_PADDING + headerHeight;
|
|
113
115
|
|
|
114
116
|
// Estimate column widths based on content
|
|
@@ -189,7 +191,8 @@ function computeLayout(
|
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
const totalWidth = currentX - COLUMN_GAP + DIAGRAM_PADDING;
|
|
192
|
-
const
|
|
194
|
+
const legendSpace = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT : 0;
|
|
195
|
+
const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING + legendSpace;
|
|
193
196
|
|
|
194
197
|
return { columns: columnLayouts, totalWidth, totalHeight };
|
|
195
198
|
}
|
|
@@ -237,18 +240,19 @@ export function renderKanban(
|
|
|
237
240
|
.text(parsed.title);
|
|
238
241
|
}
|
|
239
242
|
|
|
240
|
-
// Legend (
|
|
243
|
+
// Legend (bottom of diagram)
|
|
241
244
|
if (parsed.tagGroups.length > 0) {
|
|
242
|
-
const legendY =
|
|
243
|
-
|
|
244
|
-
const titleTextWidth = parsed.title
|
|
245
|
-
? parsed.title.length * TITLE_FONT_SIZE * 0.6 + 16
|
|
246
|
-
: 0;
|
|
247
|
-
let legendX = DIAGRAM_PADDING + titleTextWidth;
|
|
245
|
+
const legendY = height - LEGEND_HEIGHT;
|
|
246
|
+
let legendX = DIAGRAM_PADDING;
|
|
248
247
|
const groupBg = isDark
|
|
249
248
|
? mix(palette.surface, palette.bg, 50)
|
|
250
249
|
: mix(palette.surface, palette.bg, 30);
|
|
251
|
-
const capsulePad =
|
|
250
|
+
const capsulePad = LEGEND_CAPSULE_PAD;
|
|
251
|
+
|
|
252
|
+
const legendContainer = svg.append('g').attr('class', 'kanban-legend');
|
|
253
|
+
if (activeTagGroup) {
|
|
254
|
+
legendContainer.attr('data-legend-active', activeTagGroup.toLowerCase());
|
|
255
|
+
}
|
|
252
256
|
|
|
253
257
|
for (const group of parsed.tagGroups) {
|
|
254
258
|
const isActive =
|
|
@@ -257,7 +261,7 @@ export function renderKanban(
|
|
|
257
261
|
// When a group is active, skip all other groups entirely
|
|
258
262
|
if (activeTagGroup != null && !isActive) continue;
|
|
259
263
|
|
|
260
|
-
const pillTextWidth = group.name.length *
|
|
264
|
+
const pillTextWidth = group.name.length * LEGEND_PILL_FONT_SIZE * 0.6;
|
|
261
265
|
const pillWidth = pillTextWidth + 16;
|
|
262
266
|
|
|
263
267
|
// Measure total capsule width for active groups (pill + entries)
|
|
@@ -272,7 +276,7 @@ export function renderKanban(
|
|
|
272
276
|
|
|
273
277
|
// Outer capsule background for active group
|
|
274
278
|
if (isActive) {
|
|
275
|
-
|
|
279
|
+
legendContainer
|
|
276
280
|
.append('rect')
|
|
277
281
|
.attr('x', legendX)
|
|
278
282
|
.attr('y', legendY)
|
|
@@ -286,7 +290,7 @@ export function renderKanban(
|
|
|
286
290
|
|
|
287
291
|
// Pill background
|
|
288
292
|
const pillBg = isActive ? palette.bg : groupBg;
|
|
289
|
-
|
|
293
|
+
legendContainer
|
|
290
294
|
.append('rect')
|
|
291
295
|
.attr('x', pillX)
|
|
292
296
|
.attr('y', legendY + (isActive ? capsulePad : 0))
|
|
@@ -298,7 +302,7 @@ export function renderKanban(
|
|
|
298
302
|
.attr('data-legend-group', group.name.toLowerCase());
|
|
299
303
|
|
|
300
304
|
if (isActive) {
|
|
301
|
-
|
|
305
|
+
legendContainer
|
|
302
306
|
.append('rect')
|
|
303
307
|
.attr('x', pillX)
|
|
304
308
|
.attr('y', legendY + capsulePad)
|
|
@@ -311,11 +315,11 @@ export function renderKanban(
|
|
|
311
315
|
}
|
|
312
316
|
|
|
313
317
|
// Pill text
|
|
314
|
-
|
|
318
|
+
legendContainer
|
|
315
319
|
.append('text')
|
|
316
320
|
.attr('x', pillX + pillWidth / 2)
|
|
317
|
-
.attr('y', legendY + LEGEND_HEIGHT / 2 +
|
|
318
|
-
.attr('font-size',
|
|
321
|
+
.attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
322
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
319
323
|
.attr('font-weight', '500')
|
|
320
324
|
.attr('fill', isActive ? palette.text : palette.textMuted)
|
|
321
325
|
.attr('text-anchor', 'middle')
|
|
@@ -325,7 +329,7 @@ export function renderKanban(
|
|
|
325
329
|
if (isActive) {
|
|
326
330
|
let entryX = pillX + pillWidth + 4;
|
|
327
331
|
for (const entry of group.entries) {
|
|
328
|
-
const entryG =
|
|
332
|
+
const entryG = legendContainer
|
|
329
333
|
.append('g')
|
|
330
334
|
.attr('data-legend-entry', entry.value.toLowerCase())
|
|
331
335
|
.style('cursor', 'pointer');
|
package/src/org/renderer.ts
CHANGED
|
@@ -10,6 +10,23 @@ import type { ParsedOrg } from './parser';
|
|
|
10
10
|
import type { OrgLayoutResult, OrgLayoutNode } from './layout';
|
|
11
11
|
import { parseOrg } from './parser';
|
|
12
12
|
import { layoutOrg } from './layout';
|
|
13
|
+
import {
|
|
14
|
+
LEGEND_HEIGHT,
|
|
15
|
+
LEGEND_PILL_PAD,
|
|
16
|
+
LEGEND_PILL_FONT_SIZE,
|
|
17
|
+
LEGEND_PILL_FONT_W,
|
|
18
|
+
LEGEND_CAPSULE_PAD,
|
|
19
|
+
LEGEND_DOT_R,
|
|
20
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
21
|
+
LEGEND_ENTRY_FONT_W,
|
|
22
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
23
|
+
LEGEND_ENTRY_TRAIL,
|
|
24
|
+
LEGEND_GROUP_GAP,
|
|
25
|
+
LEGEND_EYE_SIZE,
|
|
26
|
+
LEGEND_EYE_GAP,
|
|
27
|
+
EYE_OPEN_PATH,
|
|
28
|
+
EYE_CLOSED_PATH,
|
|
29
|
+
} from '../utils/legend-constants';
|
|
13
30
|
|
|
14
31
|
// ============================================================
|
|
15
32
|
// Constants
|
|
@@ -37,27 +54,7 @@ const CONTAINER_HEADER_HEIGHT = 28;
|
|
|
37
54
|
const COLLAPSE_BAR_HEIGHT = 6;
|
|
38
55
|
const COLLAPSE_BAR_INSET = 0;
|
|
39
56
|
|
|
40
|
-
//
|
|
41
|
-
const LEGEND_HEIGHT = 28;
|
|
42
|
-
const LEGEND_PILL_PAD = 16;
|
|
43
|
-
const LEGEND_PILL_FONT_SIZE = 11;
|
|
44
|
-
const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
|
|
45
|
-
const LEGEND_CAPSULE_PAD = 4;
|
|
46
|
-
const LEGEND_DOT_R = 4;
|
|
47
|
-
const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
48
|
-
const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
|
|
49
|
-
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
50
|
-
const LEGEND_ENTRY_TRAIL = 8;
|
|
51
|
-
const LEGEND_GROUP_GAP = 12;
|
|
52
|
-
const LEGEND_EYE_SIZE = 14;
|
|
53
|
-
const LEGEND_EYE_GAP = 6;
|
|
54
|
-
const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram
|
|
55
|
-
|
|
56
|
-
// Eye icon SVG paths (14×14 viewBox)
|
|
57
|
-
const EYE_OPEN_PATH =
|
|
58
|
-
'M1 7s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z M7 9.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z';
|
|
59
|
-
const EYE_CLOSED_PATH =
|
|
60
|
-
'M2.5 2.5l9 9 M1.5 7s2.2-4 5.5-4c1.2 0 2.2.5 3 1.1 M12.5 7s-2.2 4-5.5 4c-1.2 0-2.2-.5-3-1.1';
|
|
57
|
+
const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram — local, not shared
|
|
61
58
|
|
|
62
59
|
// ============================================================
|
|
63
60
|
// Color helpers
|
|
@@ -117,7 +114,7 @@ export function renderOrg(
|
|
|
117
114
|
|
|
118
115
|
const titleOffset = parsed.title ? TITLE_HEIGHT : 0;
|
|
119
116
|
const legendOnly = layout.nodes.length === 0;
|
|
120
|
-
const legendPosition = parsed.options?.['legend-position'] ?? '
|
|
117
|
+
const legendPosition = parsed.options?.['legend-position'] ?? 'bottom';
|
|
121
118
|
const hasLegend = layout.legend.length > 0;
|
|
122
119
|
|
|
123
120
|
// In app mode (not export), render the legend at a fixed size outside the
|
|
@@ -512,7 +509,7 @@ export function renderOrg(
|
|
|
512
509
|
}
|
|
513
510
|
|
|
514
511
|
// Choose parent: unscaled group for fixedLegend, contentG for legend-only
|
|
515
|
-
const
|
|
512
|
+
const legendParentBase = fixedLegend
|
|
516
513
|
? svg
|
|
517
514
|
.append('g')
|
|
518
515
|
.attr('class', 'org-legend-fixed')
|
|
@@ -523,6 +520,10 @@ export function renderOrg(
|
|
|
523
520
|
: `translate(0, ${DIAGRAM_PADDING + titleReserve})`
|
|
524
521
|
)
|
|
525
522
|
: contentG;
|
|
523
|
+
const legendParent = legendParentBase;
|
|
524
|
+
if (fixedLegend && activeTagGroup) {
|
|
525
|
+
legendParentBase.attr('data-legend-active', activeTagGroup.toLowerCase());
|
|
526
|
+
}
|
|
526
527
|
|
|
527
528
|
for (const group of visibleGroups) {
|
|
528
529
|
const isActive =
|
package/src/render.ts
CHANGED
|
@@ -52,7 +52,7 @@ export async function render(
|
|
|
52
52
|
c4Level?: 'context' | 'containers' | 'components' | 'deployment';
|
|
53
53
|
c4System?: string;
|
|
54
54
|
c4Container?: string;
|
|
55
|
-
|
|
55
|
+
tagGroup?: string;
|
|
56
56
|
},
|
|
57
57
|
): Promise<string> {
|
|
58
58
|
const theme = options?.theme ?? 'light';
|
|
@@ -75,6 +75,6 @@ export async function render(
|
|
|
75
75
|
c4Level: options?.c4Level,
|
|
76
76
|
c4System: options?.c4System,
|
|
77
77
|
c4Container: options?.c4Container,
|
|
78
|
-
|
|
78
|
+
tagGroup: options?.tagGroup,
|
|
79
79
|
});
|
|
80
80
|
}
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -25,6 +25,19 @@ import type {
|
|
|
25
25
|
import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
|
|
26
26
|
import { resolveSequenceTags } from './tag-resolution';
|
|
27
27
|
import type { ResolvedTagMap } from './tag-resolution';
|
|
28
|
+
import {
|
|
29
|
+
LEGEND_HEIGHT,
|
|
30
|
+
LEGEND_PILL_PAD,
|
|
31
|
+
LEGEND_PILL_FONT_SIZE,
|
|
32
|
+
LEGEND_PILL_FONT_W,
|
|
33
|
+
LEGEND_CAPSULE_PAD,
|
|
34
|
+
LEGEND_DOT_R,
|
|
35
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
36
|
+
LEGEND_ENTRY_FONT_W,
|
|
37
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
38
|
+
LEGEND_ENTRY_TRAIL,
|
|
39
|
+
LEGEND_GROUP_GAP,
|
|
40
|
+
} from '../utils/legend-constants';
|
|
28
41
|
|
|
29
42
|
// ============================================================
|
|
30
43
|
// Layout Constants
|
|
@@ -54,19 +67,6 @@ const NOTE_CHARS_PER_LINE = Math.floor((NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD)
|
|
|
54
67
|
const COLLAPSED_NOTE_H = 20;
|
|
55
68
|
const COLLAPSED_NOTE_W = 40;
|
|
56
69
|
|
|
57
|
-
// Legend rendering constants (consistent with org chart legend)
|
|
58
|
-
const LEGEND_HEIGHT = 28;
|
|
59
|
-
const LEGEND_PILL_PAD = 16;
|
|
60
|
-
const LEGEND_PILL_FONT_SIZE = 11;
|
|
61
|
-
const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
|
|
62
|
-
const LEGEND_CAPSULE_PAD = 4;
|
|
63
|
-
const LEGEND_DOT_R = 4;
|
|
64
|
-
const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
65
|
-
const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
|
|
66
|
-
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
67
|
-
const LEGEND_ENTRY_TRAIL = 8;
|
|
68
|
-
const LEGEND_GROUP_GAP = 12;
|
|
69
|
-
const LEGEND_BOTTOM_GAP = 8;
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
function wrapTextLines(text: string, maxChars: number): string[] {
|
|
@@ -1282,12 +1282,10 @@ export function renderSequenceDiagram(
|
|
|
1282
1282
|
|
|
1283
1283
|
// Compute cumulative Y positions for each step, with section dividers as stable anchors
|
|
1284
1284
|
const titleOffset = title ? TITLE_HEIGHT : 0;
|
|
1285
|
-
const legendOffset =
|
|
1286
|
-
parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + LEGEND_BOTTOM_GAP : 0;
|
|
1287
1285
|
const groupOffset =
|
|
1288
1286
|
groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
|
|
1289
1287
|
const participantStartY =
|
|
1290
|
-
TOP_MARGIN + titleOffset +
|
|
1288
|
+
TOP_MARGIN + titleOffset + PARTICIPANT_Y_OFFSET + groupOffset;
|
|
1291
1289
|
const lifelineStartY0 = participantStartY + PARTICIPANT_BOX_HEIGHT;
|
|
1292
1290
|
const hasActors = participants.some((p) => p.type === 'actor');
|
|
1293
1291
|
const messageStartOffset = MESSAGE_START_OFFSET + (hasActors ? 20 : 0);
|
|
@@ -1388,11 +1386,13 @@ export function renderSequenceDiagram(
|
|
|
1388
1386
|
participants.length * PARTICIPANT_GAP,
|
|
1389
1387
|
PARTICIPANT_BOX_WIDTH + 40
|
|
1390
1388
|
);
|
|
1391
|
-
const
|
|
1389
|
+
const contentHeight =
|
|
1392
1390
|
participantStartY +
|
|
1393
1391
|
PARTICIPANT_BOX_HEIGHT +
|
|
1394
1392
|
Math.max(lifelineLength, 40) +
|
|
1395
1393
|
40;
|
|
1394
|
+
const legendSpace = parsed.tagGroups.length > 0 ? LEGEND_HEIGHT : 0;
|
|
1395
|
+
const totalHeight = contentHeight + legendSpace;
|
|
1396
1396
|
|
|
1397
1397
|
const containerWidth = options?.exportWidth ?? container.getBoundingClientRect().width;
|
|
1398
1398
|
const svgWidth = Math.max(totalWidth, containerWidth);
|
|
@@ -1571,7 +1571,7 @@ export function renderSequenceDiagram(
|
|
|
1571
1571
|
|
|
1572
1572
|
// Render legend pills for tag groups
|
|
1573
1573
|
if (parsed.tagGroups.length > 0) {
|
|
1574
|
-
const legendY =
|
|
1574
|
+
const legendY = contentHeight;
|
|
1575
1575
|
const groupBg = isDark
|
|
1576
1576
|
? mix(palette.surface, palette.bg, 50)
|
|
1577
1577
|
: mix(palette.surface, palette.bg, 30);
|
|
@@ -1615,8 +1615,13 @@ export function renderSequenceDiagram(
|
|
|
1615
1615
|
(legendItems.length - 1) * LEGEND_GROUP_GAP;
|
|
1616
1616
|
let legendX = (svgWidth - totalLegendWidth) / 2;
|
|
1617
1617
|
|
|
1618
|
+
const legendContainer = svg.append('g').attr('class', 'sequence-legend');
|
|
1619
|
+
if (activeTagGroup) {
|
|
1620
|
+
legendContainer.attr('data-legend-active', activeTagGroup.toLowerCase());
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1618
1623
|
for (const item of legendItems) {
|
|
1619
|
-
const gEl =
|
|
1624
|
+
const gEl = legendContainer
|
|
1620
1625
|
.append('g')
|
|
1621
1626
|
.attr('transform', `translate(${legendX}, ${legendY})`)
|
|
1622
1627
|
.attr('class', 'sequence-legend-group')
|
package/src/sitemap/layout.ts
CHANGED
|
@@ -699,32 +699,25 @@ export function layoutSitemap(
|
|
|
699
699
|
activeTagGroup != null || allExpanded ? g.width : g.minifiedWidth;
|
|
700
700
|
|
|
701
701
|
if (visibleGroups.length > 0) {
|
|
702
|
-
//
|
|
702
|
+
// Bottom position: horizontal row below chart content
|
|
703
703
|
const legendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP;
|
|
704
704
|
|
|
705
|
-
// Push chart content down
|
|
706
|
-
for (const n of layoutNodes) n.y += legendShift;
|
|
707
|
-
for (const c of layoutContainers) c.y += legendShift;
|
|
708
|
-
for (const e of layoutEdges) {
|
|
709
|
-
for (const p of e.points) p.y += legendShift;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
705
|
const totalGroupsWidth =
|
|
713
706
|
visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
|
|
714
707
|
(visibleGroups.length - 1) * LEGEND_GROUP_GAP;
|
|
715
708
|
|
|
716
|
-
|
|
709
|
+
// Center legend groups horizontally
|
|
710
|
+
const neededWidth = totalGroupsWidth + MARGIN * 2;
|
|
711
|
+
if (neededWidth > totalWidth) totalWidth = neededWidth;
|
|
712
|
+
let cx = (totalWidth - totalGroupsWidth) / 2;
|
|
713
|
+
const legendY = totalHeight + LEGEND_GROUP_GAP;
|
|
717
714
|
for (const g of visibleGroups) {
|
|
718
715
|
g.x = cx;
|
|
719
|
-
g.y =
|
|
716
|
+
g.y = legendY;
|
|
720
717
|
cx += effectiveW(g) + LEGEND_GROUP_GAP;
|
|
721
718
|
}
|
|
722
719
|
|
|
723
720
|
totalHeight += legendShift;
|
|
724
|
-
const neededWidth = totalGroupsWidth + MARGIN * 2;
|
|
725
|
-
if (neededWidth > totalWidth) {
|
|
726
|
-
totalWidth = neededWidth;
|
|
727
|
-
}
|
|
728
721
|
}
|
|
729
722
|
|
|
730
723
|
return {
|
package/src/sitemap/renderer.ts
CHANGED
|
@@ -15,6 +15,23 @@ import type {
|
|
|
15
15
|
SitemapContainerBounds,
|
|
16
16
|
SitemapLegendGroup,
|
|
17
17
|
} from './layout';
|
|
18
|
+
import {
|
|
19
|
+
LEGEND_HEIGHT,
|
|
20
|
+
LEGEND_PILL_PAD,
|
|
21
|
+
LEGEND_PILL_FONT_SIZE,
|
|
22
|
+
LEGEND_PILL_FONT_W,
|
|
23
|
+
LEGEND_CAPSULE_PAD,
|
|
24
|
+
LEGEND_DOT_R,
|
|
25
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
26
|
+
LEGEND_ENTRY_FONT_W,
|
|
27
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
28
|
+
LEGEND_ENTRY_TRAIL,
|
|
29
|
+
LEGEND_GROUP_GAP,
|
|
30
|
+
LEGEND_EYE_SIZE,
|
|
31
|
+
LEGEND_EYE_GAP,
|
|
32
|
+
EYE_OPEN_PATH,
|
|
33
|
+
EYE_CLOSED_PATH,
|
|
34
|
+
} from '../utils/legend-constants';
|
|
18
35
|
|
|
19
36
|
// ============================================================
|
|
20
37
|
// Constants
|
|
@@ -44,21 +61,7 @@ const EDGE_LABEL_FONT_SIZE = 11;
|
|
|
44
61
|
// Collapsed-node accent bar
|
|
45
62
|
const COLLAPSE_BAR_HEIGHT = 6;
|
|
46
63
|
|
|
47
|
-
//
|
|
48
|
-
const LEGEND_HEIGHT = 28;
|
|
49
|
-
const LEGEND_FIXED_GAP = 8;
|
|
50
|
-
const LEGEND_PILL_PAD = 16;
|
|
51
|
-
const LEGEND_PILL_FONT_SIZE = 11;
|
|
52
|
-
const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
|
|
53
|
-
const LEGEND_CAPSULE_PAD = 4;
|
|
54
|
-
const LEGEND_DOT_R = 4;
|
|
55
|
-
const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
56
|
-
const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
|
|
57
|
-
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
58
|
-
const LEGEND_ENTRY_TRAIL = 8;
|
|
59
|
-
const LEGEND_GROUP_GAP = 12;
|
|
60
|
-
const LEGEND_EYE_SIZE = 14;
|
|
61
|
-
const LEGEND_EYE_GAP = 6;
|
|
64
|
+
const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram — local, not shared
|
|
62
65
|
|
|
63
66
|
// ============================================================
|
|
64
67
|
// Color helpers
|
|
@@ -119,16 +122,17 @@ export function renderSitemap(
|
|
|
119
122
|
|
|
120
123
|
const hasLegend = layout.legend.length > 0;
|
|
121
124
|
|
|
122
|
-
// In app mode (not export), render the title
|
|
123
|
-
//
|
|
124
|
-
// Layout order: Title →
|
|
125
|
+
// In app mode (not export), render the title at fixed size outside the scaled group
|
|
126
|
+
// and legend at fixed size at the bottom.
|
|
127
|
+
// Layout order: Title → Diagram content → Legend (bottom).
|
|
125
128
|
const layoutLegendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP; // 40px — what layout added
|
|
126
129
|
const fixedLegend = !exportDims && hasLegend;
|
|
127
130
|
const fixedTitle = fixedLegend && !!parsed.title;
|
|
128
131
|
const fixedTitleH = fixedTitle ? TITLE_HEIGHT : 0;
|
|
129
132
|
const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
|
|
130
|
-
//
|
|
131
|
-
const
|
|
133
|
+
// Space reserved above content (title only), and below content (legend)
|
|
134
|
+
const fixedReserveTop = fixedTitleH;
|
|
135
|
+
const fixedReserveBottom = legendReserveH;
|
|
132
136
|
// Title inside scaled group only when legend is NOT fixed
|
|
133
137
|
const titleOffset = !fixedTitle && parsed.title ? TITLE_HEIGHT : 0;
|
|
134
138
|
|
|
@@ -139,14 +143,14 @@ export function renderSitemap(
|
|
|
139
143
|
// Remove the legend space from diagram height — legend is rendered separately
|
|
140
144
|
diagramH -= layoutLegendShift;
|
|
141
145
|
}
|
|
142
|
-
const availH = height - DIAGRAM_PADDING * 2 -
|
|
146
|
+
const availH = height - DIAGRAM_PADDING * 2 - fixedReserveTop - fixedReserveBottom;
|
|
143
147
|
const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
|
|
144
148
|
const scaleY = availH / diagramH;
|
|
145
149
|
const scale = Math.min(MAX_SCALE, scaleX, scaleY);
|
|
146
150
|
|
|
147
151
|
const scaledW = diagramW * scale;
|
|
148
152
|
const offsetX = (width - scaledW) / 2;
|
|
149
|
-
const offsetY = DIAGRAM_PADDING +
|
|
153
|
+
const offsetY = DIAGRAM_PADDING + fixedReserveTop;
|
|
150
154
|
|
|
151
155
|
// Create SVG
|
|
152
156
|
const svg = d3Selection
|
|
@@ -542,7 +546,10 @@ export function renderSitemap(
|
|
|
542
546
|
const legendParent = svg
|
|
543
547
|
.append('g')
|
|
544
548
|
.attr('class', 'sitemap-legend-fixed')
|
|
545
|
-
.attr('transform', `translate(0, ${DIAGRAM_PADDING
|
|
549
|
+
.attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`);
|
|
550
|
+
if (activeTagGroup) {
|
|
551
|
+
legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
|
|
552
|
+
}
|
|
546
553
|
renderLegend(legendParent, layout.legend, palette, isDark, activeTagGroup, width, hiddenAttributes);
|
|
547
554
|
}
|
|
548
555
|
}
|
|
@@ -551,12 +558,6 @@ export function renderSitemap(
|
|
|
551
558
|
// Legend rendering
|
|
552
559
|
// ============================================================
|
|
553
560
|
|
|
554
|
-
// Eye icon SVG paths (14×14 viewBox)
|
|
555
|
-
const EYE_OPEN_PATH =
|
|
556
|
-
'M1 7s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z M7 9.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z';
|
|
557
|
-
const EYE_CLOSED_PATH =
|
|
558
|
-
'M2.5 2.5l9 9 M1.5 7s2.2-4 5.5-4c1.2 0 2.2.5 3 1.1 M12.5 7s-2.2 4-5.5 4c-1.2 0-2.2-.5-3-1.1';
|
|
559
|
-
|
|
560
561
|
function renderLegend(
|
|
561
562
|
parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
562
563
|
legendGroups: SitemapLegendGroup[],
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Shared legend rendering constants
|
|
3
|
+
// All renderers import from here to stay in sync.
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
export const LEGEND_HEIGHT = 28;
|
|
7
|
+
export const LEGEND_PILL_PAD = 16;
|
|
8
|
+
export const LEGEND_PILL_FONT_SIZE = 11;
|
|
9
|
+
export const LEGEND_PILL_FONT_W = LEGEND_PILL_FONT_SIZE * 0.6;
|
|
10
|
+
export const LEGEND_CAPSULE_PAD = 4;
|
|
11
|
+
export const LEGEND_DOT_R = 4;
|
|
12
|
+
export const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
13
|
+
export const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
|
|
14
|
+
export const LEGEND_ENTRY_DOT_GAP = 4;
|
|
15
|
+
export const LEGEND_ENTRY_TRAIL = 8;
|
|
16
|
+
export const LEGEND_GROUP_GAP = 12;
|
|
17
|
+
export const LEGEND_EYE_SIZE = 14;
|
|
18
|
+
export const LEGEND_EYE_GAP = 6;
|
|
19
|
+
|
|
20
|
+
// Eye icon SVG paths (14×14 viewBox)
|
|
21
|
+
// Present only in org and sitemap legends (metadata visibility toggle)
|
|
22
|
+
export const EYE_OPEN_PATH =
|
|
23
|
+
'M1 7s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z M7 9.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z';
|
|
24
|
+
export const EYE_CLOSED_PATH =
|
|
25
|
+
'M2.5 2.5l9 9 M1.5 7s2.2-4 5.5-4c1.2 0 2.2.5 3 1.1 M12.5 7s-2.2 4-5.5 4c-1.2 0-2.2-.5-3-1.1';
|