@transitrix/diagrams 1.8.1 → 1.8.3
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/__tests__/node-size-presets.test.d.ts +1 -0
- package/dist/__tests__/node-size-presets.test.js +30 -0
- package/dist/activities/layout.js +1 -1
- package/dist/blocks/__tests__/layout.test.js +2 -2
- package/dist/blocks/layout.js +4 -3
- package/dist/capability-map/dsm-layout.js +1 -1
- package/dist/capability-map/layout-tree.d.ts +1 -1
- package/dist/capability-map/layout-tree.js +1 -1
- package/dist/fgca/preview-layout.d.ts +2 -2
- package/dist/fgca/preview-layout.js +3 -2
- package/dist/node-size-presets.d.ts +17 -4
- package/dist/node-size-presets.js +86 -72
- package/dist/webview/__tests__/entity-text-layout.test.js +115 -3
- package/dist/webview/__tests__/render-blocks.test.js +9 -6
- package/dist/webview/__tests__/render-goals.test.js +41 -0
- package/dist/webview/entity-text-layout.d.ts +31 -3
- package/dist/webview/entity-text-layout.js +123 -57
- package/package.json +1 -1
- package/src/__tests__/node-size-presets.test.ts +50 -0
- package/src/activities/layout.ts +1 -1
- package/src/blocks/__tests__/layout.test.ts +2 -2
- package/src/blocks/layout.ts +4 -3
- package/src/capability-map/dsm-layout.ts +1 -1
- package/src/capability-map/layout-tree.ts +1 -1
- package/src/fgca/preview-layout.ts +3 -2
- package/src/node-size-presets.ts +95 -72
- package/src/webview/__tests__/entity-text-layout.test.ts +132 -2
- package/src/webview/__tests__/render-blocks.test.ts +11 -6
- package/src/webview/__tests__/render-goals.test.ts +42 -0
- package/src/webview/entity-text-layout.ts +140 -54
package/src/node-size-presets.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Block/cell size presets for diagram renderers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Entity nodes (Goals, DGCA/DGA, Blocks leaves, Activities, Capability Map)
|
|
5
|
+
* share one width × height ladder. Heights use fixed tiers (72 / 80 / 96 px)
|
|
6
|
+
* so name + type + id never collide; widths scale independently (200 / 250 / 320).
|
|
7
|
+
*
|
|
8
|
+
* Process Blueprint uses a separate cell grid, scaled from the same tiers.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
export type NodeSizePreset = 'compact' | 'normal' | 'wide';
|
|
@@ -39,82 +42,102 @@ export interface CapabilityMapDimensions {
|
|
|
39
42
|
|
|
40
43
|
type PresetTable<T> = Record<NodeSizePreset, T>;
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
/** Shared entity-node width tiers (px). */
|
|
46
|
+
export const ENTITY_NODE_WIDTH: Record<NodeSizePreset, number> = {
|
|
47
|
+
compact: 200,
|
|
48
|
+
normal: 250,
|
|
49
|
+
wide: 320,
|
|
46
50
|
};
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
/** Shared entity-node height tiers (px) — room for name + type + id. */
|
|
53
|
+
export const ENTITY_NODE_HEIGHT: Record<NodeSizePreset, number> = {
|
|
54
|
+
compact: 72,
|
|
55
|
+
normal: 80,
|
|
56
|
+
wide: 96,
|
|
52
57
|
};
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
function entityNodeSize(preset: NodeSizePreset): BoxDimensions {
|
|
60
|
+
return { width: ENTITY_NODE_WIDTH[preset], height: ENTITY_NODE_HEIGHT[preset] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Canonical entity-node sizes — used by every box-based notation preview. */
|
|
64
|
+
export const ENTITY_NODE_SIZE: PresetTable<BoxDimensions> = {
|
|
65
|
+
compact: entityNodeSize('compact'),
|
|
66
|
+
normal: entityNodeSize('normal'),
|
|
67
|
+
wide: entityNodeSize('wide'),
|
|
58
68
|
};
|
|
59
69
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
/** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
|
|
71
|
+
export const GOALS_NODE_SIZE = ENTITY_NODE_SIZE;
|
|
72
|
+
/** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
|
|
73
|
+
export const DGCA_NODE_SIZE = ENTITY_NODE_SIZE;
|
|
74
|
+
/** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
|
|
75
|
+
export const BLOCKS_LEAF_SIZE = ENTITY_NODE_SIZE;
|
|
76
|
+
/** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
|
|
77
|
+
export const ACTION_NODE_SIZE = ENTITY_NODE_SIZE;
|
|
78
|
+
|
|
79
|
+
export const CAPABILITY_MAP_NODE_SIZE: PresetTable<CapabilityMapDimensions> = {
|
|
80
|
+
compact: { nodeWidth: ENTITY_NODE_WIDTH.compact, nodeHeight: ENTITY_NODE_HEIGHT.compact },
|
|
81
|
+
normal: { nodeWidth: ENTITY_NODE_WIDTH.normal, nodeHeight: ENTITY_NODE_HEIGHT.normal },
|
|
82
|
+
wide: { nodeWidth: ENTITY_NODE_WIDTH.wide, nodeHeight: ENTITY_NODE_HEIGHT.wide },
|
|
64
83
|
};
|
|
65
84
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
cellTextPadY: 10,
|
|
81
|
-
},
|
|
82
|
-
normal: {
|
|
83
|
-
legendColumnWidth: 140,
|
|
84
|
-
stageColumnWidth: 220,
|
|
85
|
-
stageHeaderHeight: 40,
|
|
86
|
-
goalRowHeight: 56,
|
|
87
|
-
resultRowHeight: 56,
|
|
88
|
-
aspectRowMinHeight: 60,
|
|
89
|
-
pillHeight: 40,
|
|
90
|
-
pillGap: 4,
|
|
91
|
-
cellPadding: 8,
|
|
92
|
-
textLineHeight: 17,
|
|
93
|
-
textCharWidth: 7.5,
|
|
94
|
-
cellTextPadX: 10,
|
|
95
|
-
cellTextPadY: 10,
|
|
96
|
-
},
|
|
97
|
-
wide: {
|
|
98
|
-
legendColumnWidth: 160,
|
|
99
|
-
stageColumnWidth: 280,
|
|
100
|
-
stageHeaderHeight: 44,
|
|
101
|
-
goalRowHeight: 60,
|
|
102
|
-
resultRowHeight: 60,
|
|
103
|
-
aspectRowMinHeight: 64,
|
|
104
|
-
pillHeight: 44,
|
|
105
|
-
pillGap: 4,
|
|
106
|
-
cellPadding: 8,
|
|
107
|
-
textLineHeight: 18,
|
|
108
|
-
textCharWidth: 7.5,
|
|
109
|
-
cellTextPadX: 10,
|
|
110
|
-
cellTextPadY: 10,
|
|
111
|
-
},
|
|
85
|
+
const PROCESS_BLUEPRINT_NORMAL: ProcessBlueprintDimensions = {
|
|
86
|
+
legendColumnWidth: 140,
|
|
87
|
+
stageColumnWidth: 220,
|
|
88
|
+
stageHeaderHeight: 40,
|
|
89
|
+
goalRowHeight: 56,
|
|
90
|
+
resultRowHeight: 56,
|
|
91
|
+
aspectRowMinHeight: 60,
|
|
92
|
+
pillHeight: 40,
|
|
93
|
+
pillGap: 4,
|
|
94
|
+
cellPadding: 8,
|
|
95
|
+
textLineHeight: 17,
|
|
96
|
+
textCharWidth: 7.5,
|
|
97
|
+
cellTextPadX: 10,
|
|
98
|
+
cellTextPadY: 10,
|
|
112
99
|
};
|
|
113
100
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
101
|
+
function scaleBlueprint(
|
|
102
|
+
base: ProcessBlueprintDimensions,
|
|
103
|
+
widthScale: number,
|
|
104
|
+
heightScale: number,
|
|
105
|
+
): ProcessBlueprintDimensions {
|
|
106
|
+
const w = (n: number) => Math.round(n * widthScale);
|
|
107
|
+
const h = (n: number) => Math.round(n * heightScale);
|
|
108
|
+
return {
|
|
109
|
+
legendColumnWidth: w(base.legendColumnWidth),
|
|
110
|
+
stageColumnWidth: w(base.stageColumnWidth),
|
|
111
|
+
stageHeaderHeight: h(base.stageHeaderHeight),
|
|
112
|
+
goalRowHeight: h(base.goalRowHeight),
|
|
113
|
+
resultRowHeight: h(base.resultRowHeight),
|
|
114
|
+
aspectRowMinHeight: h(base.aspectRowMinHeight),
|
|
115
|
+
pillHeight: h(base.pillHeight),
|
|
116
|
+
pillGap: base.pillGap,
|
|
117
|
+
cellPadding: base.cellPadding,
|
|
118
|
+
textLineHeight: h(base.textLineHeight),
|
|
119
|
+
textCharWidth: base.textCharWidth,
|
|
120
|
+
cellTextPadX: base.cellTextPadX,
|
|
121
|
+
cellTextPadY: base.cellTextPadY,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const PB_WIDTH_SCALE: Record<NodeSizePreset, number> = {
|
|
126
|
+
compact: ENTITY_NODE_WIDTH.compact / ENTITY_NODE_WIDTH.normal,
|
|
127
|
+
normal: 1,
|
|
128
|
+
wide: ENTITY_NODE_WIDTH.wide / ENTITY_NODE_WIDTH.normal,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const PB_HEIGHT_SCALE: Record<NodeSizePreset, number> = {
|
|
132
|
+
compact: ENTITY_NODE_HEIGHT.compact / ENTITY_NODE_HEIGHT.normal,
|
|
133
|
+
normal: 1,
|
|
134
|
+
wide: ENTITY_NODE_HEIGHT.wide / ENTITY_NODE_HEIGHT.normal,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const PROCESS_BLUEPRINT_SIZE: PresetTable<ProcessBlueprintDimensions> = {
|
|
138
|
+
compact: scaleBlueprint(PROCESS_BLUEPRINT_NORMAL, PB_WIDTH_SCALE.compact, PB_HEIGHT_SCALE.compact),
|
|
139
|
+
normal: PROCESS_BLUEPRINT_NORMAL,
|
|
140
|
+
wide: scaleBlueprint(PROCESS_BLUEPRINT_NORMAL, PB_WIDTH_SCALE.wide, PB_HEIGHT_SCALE.wide),
|
|
118
141
|
};
|
|
119
142
|
|
|
120
143
|
export type NodeSizeNotation =
|
|
@@ -133,19 +156,19 @@ export function parseNodeSizePreset(value: string | undefined): NodeSizePreset {
|
|
|
133
156
|
}
|
|
134
157
|
|
|
135
158
|
export function resolveGoalsNodeSize(preset: NodeSizePreset): BoxDimensions {
|
|
136
|
-
return
|
|
159
|
+
return ENTITY_NODE_SIZE[preset];
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
export function resolveDgcaNodeSize(preset: NodeSizePreset): BoxDimensions {
|
|
140
|
-
return
|
|
163
|
+
return ENTITY_NODE_SIZE[preset];
|
|
141
164
|
}
|
|
142
165
|
|
|
143
166
|
export function resolveBlocksLeafSize(preset: NodeSizePreset): BlocksLeafDimensions {
|
|
144
|
-
return
|
|
167
|
+
return ENTITY_NODE_SIZE[preset];
|
|
145
168
|
}
|
|
146
169
|
|
|
147
170
|
export function resolveActionNodeSize(preset: NodeSizePreset): BoxDimensions {
|
|
148
|
-
return
|
|
171
|
+
return ENTITY_NODE_SIZE[preset];
|
|
149
172
|
}
|
|
150
173
|
|
|
151
174
|
export function resolveProcessBlueprintSize(preset: NodeSizePreset): ProcessBlueprintDimensions {
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
layoutCenteredEntityText,
|
|
4
|
+
layoutLeafBlockText,
|
|
5
|
+
layoutHeaderBlockText,
|
|
6
|
+
LINE_H_PRIMARY,
|
|
7
|
+
LINE_H_SECONDARY,
|
|
8
|
+
TEXT_MARGIN_Y,
|
|
4
9
|
maxCharsForInnerWidth,
|
|
10
|
+
ROW_GROUP_GAP,
|
|
5
11
|
wrapWords,
|
|
6
12
|
wrapTextLines,
|
|
7
13
|
} from '../entity-text-layout.js';
|
|
14
|
+
import { ENTITY_NODE_SIZE } from '../../node-size-presets.js';
|
|
8
15
|
|
|
9
16
|
describe('entity-text-layout', () => {
|
|
10
17
|
it('wrapWords respects max lines and truncates with ellipsis', () => {
|
|
@@ -18,12 +25,30 @@ describe('entity-text-layout', () => {
|
|
|
18
25
|
expect(maxCharsForInnerWidth(160, 7)).toBeGreaterThan(maxCharsForInnerWidth(120, 7));
|
|
19
26
|
});
|
|
20
27
|
|
|
28
|
+
it('layoutCenteredEntityText keeps name and type labels separated', () => {
|
|
29
|
+
const specs = layoutCenteredEntityText({
|
|
30
|
+
boxX: 0,
|
|
31
|
+
boxY: 0,
|
|
32
|
+
boxWidth: 250,
|
|
33
|
+
boxHeight: 80,
|
|
34
|
+
name: 'Grow enterprise value',
|
|
35
|
+
type: 'Strategic',
|
|
36
|
+
id: 'GOAL-VALUE-1',
|
|
37
|
+
nameMaxLines: 2,
|
|
38
|
+
idMaxLines: 1,
|
|
39
|
+
});
|
|
40
|
+
const nameY = specs.find((l) => l.cls === 'text-primary')!.y;
|
|
41
|
+
const typeY = specs.find((l) => l.cls === 'text-secondary')!.y;
|
|
42
|
+
const minGap = typeY - nameY - LINE_H_PRIMARY / 2 - LINE_H_SECONDARY / 2;
|
|
43
|
+
expect(minGap).toBeGreaterThanOrEqual(ROW_GROUP_GAP - 1);
|
|
44
|
+
});
|
|
45
|
+
|
|
21
46
|
it('layoutCenteredEntityText emits more name capacity on wide boxes', () => {
|
|
22
47
|
const narrow = layoutCenteredEntityText({
|
|
23
48
|
boxX: 0,
|
|
24
49
|
boxY: 0,
|
|
25
50
|
boxWidth: 200,
|
|
26
|
-
boxHeight:
|
|
51
|
+
boxHeight: 80,
|
|
27
52
|
name: 'Improve customer onboarding experience across regions',
|
|
28
53
|
id: 'GOAL-001',
|
|
29
54
|
nameMaxLines: 2,
|
|
@@ -33,7 +58,7 @@ describe('entity-text-layout', () => {
|
|
|
33
58
|
boxX: 0,
|
|
34
59
|
boxY: 0,
|
|
35
60
|
boxWidth: 320,
|
|
36
|
-
boxHeight:
|
|
61
|
+
boxHeight: 80,
|
|
37
62
|
name: 'Improve customer onboarding experience across regions',
|
|
38
63
|
id: 'GOAL-001',
|
|
39
64
|
nameMaxLines: 2,
|
|
@@ -50,3 +75,108 @@ describe('entity-text-layout', () => {
|
|
|
50
75
|
expect(lines.every((l) => l.length <= 10)).toBe(true);
|
|
51
76
|
});
|
|
52
77
|
});
|
|
78
|
+
|
|
79
|
+
// Approximate half-extent (px) of each text class's glyphs around its centre
|
|
80
|
+
// y — font-size / 2, the same heuristic the render-blocks overlap regression
|
|
81
|
+
// test (#419) uses. Used below to assert text never visually crosses the box
|
|
82
|
+
// border, not just that the *nominal* line-height budget fits.
|
|
83
|
+
const HALF_EXTENT: Record<string, number> = {
|
|
84
|
+
'text-primary': 6, // 12px font
|
|
85
|
+
'text-secondary': 6, // 11px font, rounded up
|
|
86
|
+
'text-id': 5, // 10px font
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
describe('entity text layout — never overflows the box, always keeps padding', () => {
|
|
90
|
+
const PRESETS = Object.entries(ENTITY_NODE_SIZE); // compact/normal/wide
|
|
91
|
+
const NAMES = [
|
|
92
|
+
'Short',
|
|
93
|
+
'Achieve full GDPR & NIS2 compliance',
|
|
94
|
+
'A name so long it would need three or more lines to render in full without any height limit at all',
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
function assertNoOverflowAndPadded(
|
|
98
|
+
specs: { cls: string; y: number }[],
|
|
99
|
+
boxTop: number,
|
|
100
|
+
boxBottom: number,
|
|
101
|
+
): void {
|
|
102
|
+
for (const s of specs) {
|
|
103
|
+
const half = HALF_EXTENT[s.cls] ?? 6;
|
|
104
|
+
expect(s.y - half).toBeGreaterThanOrEqual(boxTop);
|
|
105
|
+
expect(s.y + half).toBeLessThanOrEqual(boxBottom);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const [presetName, size] of PRESETS) {
|
|
110
|
+
for (const withType of [false, true]) {
|
|
111
|
+
for (const name of NAMES) {
|
|
112
|
+
it(`layoutCenteredEntityText: ${presetName} preset, type=${withType}, name="${name.slice(0, 20)}…" stays inside the box`, () => {
|
|
113
|
+
const boxY = 0;
|
|
114
|
+
const specs = layoutCenteredEntityText({
|
|
115
|
+
boxX: 0,
|
|
116
|
+
boxY,
|
|
117
|
+
boxWidth: size.width,
|
|
118
|
+
boxHeight: size.height,
|
|
119
|
+
name,
|
|
120
|
+
type: withType ? 'Strategic Goal' : undefined,
|
|
121
|
+
id: 'GOAL-EU-COMPLIANCE-1',
|
|
122
|
+
nameMaxLines: 2,
|
|
123
|
+
idMaxLines: 1,
|
|
124
|
+
});
|
|
125
|
+
assertNoOverflowAndPadded(specs, boxY, boxY + size.height);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('reproduces the reported case (wide preset, 2-line name + type + long id) with real padding on all sides', () => {
|
|
132
|
+
const size = ENTITY_NODE_SIZE.normal;
|
|
133
|
+
const boxY = 100;
|
|
134
|
+
const specs = layoutCenteredEntityText({
|
|
135
|
+
boxX: 0,
|
|
136
|
+
boxY,
|
|
137
|
+
boxWidth: size.width,
|
|
138
|
+
boxHeight: size.height,
|
|
139
|
+
name: 'Achieve full GDPR & NIS2 compliance',
|
|
140
|
+
type: 'Project Goal',
|
|
141
|
+
id: 'GOAL-EU-COMPLIANCE-1',
|
|
142
|
+
nameMaxLines: 2,
|
|
143
|
+
idMaxLines: 1,
|
|
144
|
+
});
|
|
145
|
+
const nameLines = specs.filter((s) => s.cls === 'text-primary');
|
|
146
|
+
expect(nameLines.length).toBe(2); // normal preset is tall enough to keep both lines
|
|
147
|
+
assertNoOverflowAndPadded(specs, boxY, boxY + size.height);
|
|
148
|
+
// Explicit padding, not just "doesn't cross the border".
|
|
149
|
+
const firstTop = Math.min(...specs.map((s) => s.y - (HALF_EXTENT[s.cls] ?? 6)));
|
|
150
|
+
const lastBottom = Math.max(...specs.map((s) => s.y + (HALF_EXTENT[s.cls] ?? 6)));
|
|
151
|
+
expect(firstTop - boxY).toBeGreaterThanOrEqual(TEXT_MARGIN_Y);
|
|
152
|
+
expect(boxY + size.height - lastBottom).toBeGreaterThanOrEqual(TEXT_MARGIN_Y);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('layoutLeafBlockText stays inside the box for compact preset with a long name and id', () => {
|
|
156
|
+
const size = ENTITY_NODE_SIZE.compact;
|
|
157
|
+
const boxY = 0;
|
|
158
|
+
const specs = layoutLeafBlockText({
|
|
159
|
+
boxX: 0,
|
|
160
|
+
boxY,
|
|
161
|
+
boxWidth: size.width,
|
|
162
|
+
boxHeight: size.height,
|
|
163
|
+
name: 'Personal data records that linger after consent withdrawal across many systems and archives',
|
|
164
|
+
id: 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE',
|
|
165
|
+
});
|
|
166
|
+
assertNoOverflowAndPadded(specs, boxY, boxY + size.height);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('layoutHeaderBlockText stays inside a short header strip', () => {
|
|
170
|
+
const boxY = 0;
|
|
171
|
+
const headerHeight = 28;
|
|
172
|
+
const specs = layoutHeaderBlockText({
|
|
173
|
+
boxX: 0,
|
|
174
|
+
boxY,
|
|
175
|
+
boxWidth: 250,
|
|
176
|
+
headerHeight,
|
|
177
|
+
name: 'A fairly long container name here',
|
|
178
|
+
id: 'VERY_LONG_CONTAINER_IDENTIFIER',
|
|
179
|
+
});
|
|
180
|
+
assertNoOverflowAndPadded(specs, boxY, boxY + headerHeight);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -11,6 +11,10 @@ import { describe, expect, it } from 'vitest';
|
|
|
11
11
|
import type { BlocksFile } from '../../blocks/types.js';
|
|
12
12
|
import { renderBlocksSvg } from '../render-blocks.js';
|
|
13
13
|
import { CHAR_W_PRIMARY as CHAR_W, CHAR_W_ID, TEXT_MARGIN_X } from '../entity-text-layout.js';
|
|
14
|
+
import { ENTITY_NODE_SIZE } from '../../node-size-presets.js';
|
|
15
|
+
|
|
16
|
+
const LEAF_W = ENTITY_NODE_SIZE.normal.width;
|
|
17
|
+
const LEAF_H = ENTITY_NODE_SIZE.normal.height;
|
|
14
18
|
|
|
15
19
|
function leafDoc(name: string, id: string): BlocksFile {
|
|
16
20
|
return {
|
|
@@ -76,7 +80,8 @@ describe('renderBlocksSvg — leaf labels', () => {
|
|
|
76
80
|
});
|
|
77
81
|
|
|
78
82
|
it('wraps a long ID to at most 2 lines and truncates with …', () => {
|
|
79
|
-
const longId =
|
|
83
|
+
const longId =
|
|
84
|
+
'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE_AND_MORE_EXTRA_TAIL_TO_FORCE_TRUNCATION';
|
|
80
85
|
const svg = renderBlocksSvg(leafDoc('Short name', longId));
|
|
81
86
|
const idLines = extractLabels(svg).filter((l) => l.cls === 'text-id');
|
|
82
87
|
expect(idLines.length).toBeGreaterThan(0);
|
|
@@ -86,7 +91,7 @@ describe('renderBlocksSvg — leaf labels', () => {
|
|
|
86
91
|
}
|
|
87
92
|
});
|
|
88
93
|
|
|
89
|
-
it(
|
|
94
|
+
it(`keeps every label inside the ${LEAF_W}px leaf width (approximated by CHAR_W * length)`, () => {
|
|
90
95
|
const svg = renderBlocksSvg(
|
|
91
96
|
leafDoc(
|
|
92
97
|
'Personal data records that linger after consent withdrawal across many systems',
|
|
@@ -94,7 +99,7 @@ describe('renderBlocksSvg — leaf labels', () => {
|
|
|
94
99
|
),
|
|
95
100
|
);
|
|
96
101
|
const labels = extractLabels(svg);
|
|
97
|
-
const inner =
|
|
102
|
+
const inner = LEAF_W - TEXT_MARGIN_X * 2;
|
|
98
103
|
for (const l of labels) {
|
|
99
104
|
const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
|
|
100
105
|
expect(l.text.length * cw).toBeLessThanOrEqual(inner);
|
|
@@ -126,8 +131,8 @@ describe('renderBlocksSvg — container headers', () => {
|
|
|
126
131
|
),
|
|
127
132
|
);
|
|
128
133
|
const labels = extractLabels(svg);
|
|
129
|
-
//
|
|
130
|
-
const containerW =
|
|
134
|
+
// Container width derives from its single leaf child: leafW + 2×padding.
|
|
135
|
+
const containerW = LEAF_W + 24;
|
|
131
136
|
const inner = containerW - TEXT_MARGIN_X * 2;
|
|
132
137
|
for (const l of labels) {
|
|
133
138
|
const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
|
|
@@ -144,7 +149,7 @@ describe('renderBlocksSvg — container headers', () => {
|
|
|
144
149
|
'Active systems that are erasure-reachable within the standard 30-day operational window';
|
|
145
150
|
const svg = renderBlocksSvg(containerDoc(longName, 'ACTIVE'));
|
|
146
151
|
const labels = extractLabels(svg);
|
|
147
|
-
const containerW =
|
|
152
|
+
const containerW = LEAF_W + 24;
|
|
148
153
|
const inner = containerW - TEXT_MARGIN_X * 2;
|
|
149
154
|
const primaries = labels.filter((l) => l.cls === 'text-primary');
|
|
150
155
|
for (const l of primaries) {
|
|
@@ -65,6 +65,48 @@ describe('renderGoalsSvg', () => {
|
|
|
65
65
|
expect(svg).toContain('height="0"');
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it('renders name, type, and id on separate lines without overlap', () => {
|
|
69
|
+
const tree: GoalTree = {
|
|
70
|
+
goal_types: [
|
|
71
|
+
{ name: 'Strategic', level: 0 },
|
|
72
|
+
{ name: 'Objective', level: 1 },
|
|
73
|
+
],
|
|
74
|
+
goals: [
|
|
75
|
+
{
|
|
76
|
+
id: 1,
|
|
77
|
+
canonical_id: 'GOAL-VALUE-1',
|
|
78
|
+
name: 'Grow enterprise value',
|
|
79
|
+
type: 'Strategic',
|
|
80
|
+
level: 0,
|
|
81
|
+
parent_id: 0,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 2,
|
|
85
|
+
canonical_id: 'GOAL-COST-1',
|
|
86
|
+
name: 'Reduce run cost 20%',
|
|
87
|
+
type: 'Objective',
|
|
88
|
+
level: 1,
|
|
89
|
+
parent_id: 1,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
const svg = renderGoalsSvg(tree);
|
|
94
|
+
expect(svg).toContain('Grow enterprise value');
|
|
95
|
+
expect(svg).toContain('Strategic');
|
|
96
|
+
expect(svg).toContain('GOAL-VALUE-1');
|
|
97
|
+
const typeMatches = [...svg.matchAll(/class="text-secondary"[^>]*y="(\d+)"/g)];
|
|
98
|
+
const nameMatches = [...svg.matchAll(/class="text-primary"[^>]*y="(\d+)"/g)];
|
|
99
|
+
expect(typeMatches.length).toBeGreaterThan(0);
|
|
100
|
+
for (const type of typeMatches) {
|
|
101
|
+
const typeY = Number(type[1]);
|
|
102
|
+
const nearestName = nameMatches
|
|
103
|
+
.map((m) => Number(m[1]))
|
|
104
|
+
.filter((y) => y < typeY)
|
|
105
|
+
.sort((a, b) => b - a)[0];
|
|
106
|
+
expect(typeY - nearestName).toBeGreaterThanOrEqual(18);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
68
110
|
it('shows canonical goal ids in node labels when present', () => {
|
|
69
111
|
const tree: GoalTree = {
|
|
70
112
|
goal_types: [{ name: 'Strategy', level: 0 }],
|