@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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ACTION_NODE_SIZE, BLOCKS_LEAF_SIZE, CAPABILITY_MAP_NODE_SIZE, DGCA_NODE_SIZE, ENTITY_NODE_HEIGHT, ENTITY_NODE_SIZE, ENTITY_NODE_WIDTH, GOALS_NODE_SIZE, PROCESS_BLUEPRINT_SIZE, resolveGoalsNodeSize, } from '../node-size-presets.js';
3
+ describe('node-size-presets', () => {
4
+ it('uses shared width and height tiers for entity nodes', () => {
5
+ expect(ENTITY_NODE_WIDTH).toEqual({ compact: 200, normal: 250, wide: 320 });
6
+ expect(ENTITY_NODE_HEIGHT).toEqual({ compact: 72, normal: 80, wide: 96 });
7
+ expect(ENTITY_NODE_SIZE.normal).toEqual({ width: 250, height: 80 });
8
+ expect(ENTITY_NODE_SIZE.compact).toEqual({ width: 200, height: 72 });
9
+ expect(ENTITY_NODE_SIZE.wide).toEqual({ width: 320, height: 96 });
10
+ });
11
+ it('exposes the same entity sizes for every box-based notation', () => {
12
+ for (const table of [GOALS_NODE_SIZE, DGCA_NODE_SIZE, BLOCKS_LEAF_SIZE, ACTION_NODE_SIZE]) {
13
+ expect(table).toBe(ENTITY_NODE_SIZE);
14
+ expect(table.normal).toEqual(ENTITY_NODE_SIZE.normal);
15
+ }
16
+ expect(CAPABILITY_MAP_NODE_SIZE.normal).toEqual({
17
+ nodeWidth: ENTITY_NODE_WIDTH.normal,
18
+ nodeHeight: ENTITY_NODE_HEIGHT.normal,
19
+ });
20
+ });
21
+ it('scales wide above normal and compact below', () => {
22
+ expect(resolveGoalsNodeSize('wide').height).toBeGreaterThan(resolveGoalsNodeSize('normal').height);
23
+ expect(resolveGoalsNodeSize('compact').height).toBeLessThan(resolveGoalsNodeSize('normal').height);
24
+ expect(resolveGoalsNodeSize('wide').width).toBeGreaterThan(resolveGoalsNodeSize('normal').width);
25
+ });
26
+ it('scales process blueprint from the same tier ratios', () => {
27
+ expect(PROCESS_BLUEPRINT_SIZE.wide.stageColumnWidth).toBeGreaterThan(PROCESS_BLUEPRINT_SIZE.normal.stageColumnWidth);
28
+ expect(PROCESS_BLUEPRINT_SIZE.compact.goalRowHeight).toBeLessThan(PROCESS_BLUEPRINT_SIZE.normal.goalRowHeight);
29
+ });
30
+ });
@@ -1,5 +1,5 @@
1
1
  import { computeCpm } from './cpm.js';
2
- const DEFAULT_NODE_W = 200;
2
+ const DEFAULT_NODE_W = 250;
3
3
  const DEFAULT_NODE_H = 80;
4
4
  const H_GAP = 80;
5
5
  const V_GAP = 24;
@@ -108,8 +108,8 @@ describe('layoutNestedBlocks', () => {
108
108
  },
109
109
  });
110
110
  const a = layout.blocks[0];
111
- expect(a.width).toBe(160);
112
- expect(a.height).toBe(72);
111
+ expect(a.width).toBe(250);
112
+ expect(a.height).toBe(80);
113
113
  });
114
114
  it('honours custom layout options', () => {
115
115
  const layout = layoutNestedBlocks({
@@ -1,8 +1,9 @@
1
+ import { ENTITY_NODE_SIZE } from '../node-size-presets.js';
1
2
  const DEFAULTS = {
2
- leafWidth: 160,
3
- leafHeight: 72,
3
+ leafWidth: ENTITY_NODE_SIZE.normal.width,
4
+ leafHeight: ENTITY_NODE_SIZE.normal.height,
4
5
  padding: 12,
5
- headerHeight: 28,
6
+ headerHeight: 32,
6
7
  childGap: 12,
7
8
  topLevelGap: 24,
8
9
  };
@@ -1,6 +1,6 @@
1
1
  import { getLevel, getParentAddress } from './address.js';
2
2
  const DEFAULT_NODE_WIDTH = 250;
3
- const DEFAULT_NODE_HEIGHT = 64;
3
+ const DEFAULT_NODE_HEIGHT = 80;
4
4
  const DEFAULT_RANK_SEP = 80;
5
5
  const DEFAULT_NODE_SEP = 24;
6
6
  /** Width of the virtual organisation-root node — narrower than a capability
@@ -1,6 +1,6 @@
1
1
  import type { CapabilityNode, CapabilityMapHeader } from './types.js';
2
2
  export declare const TREE_NODE_WIDTH = 250;
3
- export declare const TREE_NODE_HEIGHT = 64;
3
+ export declare const TREE_NODE_HEIGHT = 80;
4
4
  /** Gap between sibling columns (node width + rank sep = 330px, matching DSM). */
5
5
  export declare const TREE_RANK_SEP = 80;
6
6
  /** Vertical gap between sibling nodes. */
@@ -1,5 +1,5 @@
1
1
  export const TREE_NODE_WIDTH = 250;
2
- export const TREE_NODE_HEIGHT = 64;
2
+ export const TREE_NODE_HEIGHT = 80;
3
3
  /** Gap between sibling columns (node width + rank sep = 330px, matching DSM). */
4
4
  export const TREE_RANK_SEP = 80;
5
5
  /** Vertical gap between sibling nodes. */
@@ -82,8 +82,8 @@ export interface FGCAPreviewLayout {
82
82
  width: number;
83
83
  height: number;
84
84
  }
85
- export declare const FGCA_NODE_W = 220;
86
- export declare const FGCA_NODE_H = 72;
85
+ export declare const FGCA_NODE_W: number;
86
+ export declare const FGCA_NODE_H: number;
87
87
  export declare const FGCA_HEADER_H = 32;
88
88
  export declare const FGCA_PAD = 20;
89
89
  export declare const FGCA_DEFAULT_COL_GAP = 160;
@@ -6,6 +6,7 @@
6
6
  // so the gap geometry is unit-testable: the extension has no test harness.
7
7
  //
8
8
  // No React, no DOM, no vscode. Safe to call in Node.js or a test environment.
9
+ import { ENTITY_NODE_SIZE } from '../node-size-presets.js';
9
10
  /**
10
11
  * Trims a DGCA/DGA doc to a scope.
11
12
  *
@@ -40,8 +41,8 @@ export function selectScopedFGCA(doc, scope) {
40
41
  }
41
42
  // Default node + frame geometry. Inter-node gaps are user-configurable via
42
43
  // Inter-node gaps are user-configurable via spacing settings; node size uses presets.
43
- export const FGCA_NODE_W = 220;
44
- export const FGCA_NODE_H = 72;
44
+ export const FGCA_NODE_W = ENTITY_NODE_SIZE.normal.width;
45
+ export const FGCA_NODE_H = ENTITY_NODE_SIZE.normal.height;
45
46
  export const FGCA_HEADER_H = 32;
46
47
  export const FGCA_PAD = 20;
47
48
  export const FGCA_DEFAULT_COL_GAP = 160;
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Block/cell size presets for diagram renderers.
3
3
  *
4
- * Presets drive both layout geometry and shared text-layout char budgets.
5
- * Smooth per-pixel sliders are intentionally deferred.
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
  export type NodeSizePreset = 'compact' | 'normal' | 'wide';
8
11
  export declare const NODE_SIZE_PRESET_VALUES: readonly NodeSizePreset[];
@@ -32,12 +35,22 @@ export interface CapabilityMapDimensions {
32
35
  nodeHeight: number;
33
36
  }
34
37
  type PresetTable<T> = Record<NodeSizePreset, T>;
38
+ /** Shared entity-node width tiers (px). */
39
+ export declare const ENTITY_NODE_WIDTH: Record<NodeSizePreset, number>;
40
+ /** Shared entity-node height tiers (px) — room for name + type + id. */
41
+ export declare const ENTITY_NODE_HEIGHT: Record<NodeSizePreset, number>;
42
+ /** Canonical entity-node sizes — used by every box-based notation preview. */
43
+ export declare const ENTITY_NODE_SIZE: PresetTable<BoxDimensions>;
44
+ /** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
35
45
  export declare const GOALS_NODE_SIZE: PresetTable<BoxDimensions>;
46
+ /** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
36
47
  export declare const DGCA_NODE_SIZE: PresetTable<BoxDimensions>;
37
- export declare const BLOCKS_LEAF_SIZE: PresetTable<BlocksLeafDimensions>;
48
+ /** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
49
+ export declare const BLOCKS_LEAF_SIZE: PresetTable<BoxDimensions>;
50
+ /** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
38
51
  export declare const ACTION_NODE_SIZE: PresetTable<BoxDimensions>;
39
- export declare const PROCESS_BLUEPRINT_SIZE: PresetTable<ProcessBlueprintDimensions>;
40
52
  export declare const CAPABILITY_MAP_NODE_SIZE: PresetTable<CapabilityMapDimensions>;
53
+ export declare const PROCESS_BLUEPRINT_SIZE: PresetTable<ProcessBlueprintDimensions>;
41
54
  export type NodeSizeNotation = 'goals' | 'dgca' | 'dga' | 'blocks' | 'action' | 'processBlueprint' | 'capabilityMap';
42
55
  /** Parse a VS Code setting value into a preset id (defaults to `normal`). */
43
56
  export declare function parseNodeSizePreset(value: string | undefined): NodeSizePreset;
@@ -1,81 +1,95 @@
1
1
  /**
2
2
  * Block/cell size presets for diagram renderers.
3
3
  *
4
- * Presets drive both layout geometry and shared text-layout char budgets.
5
- * Smooth per-pixel sliders are intentionally deferred.
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
  export const NODE_SIZE_PRESET_VALUES = ['compact', 'normal', 'wide'];
8
- export const GOALS_NODE_SIZE = {
9
- compact: { width: 200, height: 72 },
10
- normal: { width: 250, height: 72 },
11
- wide: { width: 320, height: 72 },
11
+ /** Shared entity-node width tiers (px). */
12
+ export const ENTITY_NODE_WIDTH = {
13
+ compact: 200,
14
+ normal: 250,
15
+ wide: 320,
12
16
  };
13
- export const DGCA_NODE_SIZE = {
14
- compact: { width: 200, height: 72 },
15
- normal: { width: 220, height: 72 },
16
- wide: { width: 280, height: 72 },
17
+ /** Shared entity-node height tiers (px) — room for name + type + id. */
18
+ export const ENTITY_NODE_HEIGHT = {
19
+ compact: 72,
20
+ normal: 80,
21
+ wide: 96,
17
22
  };
18
- export const BLOCKS_LEAF_SIZE = {
19
- compact: { width: 140, height: 72 },
20
- normal: { width: 160, height: 72 },
21
- wide: { width: 200, height: 72 },
23
+ function entityNodeSize(preset) {
24
+ return { width: ENTITY_NODE_WIDTH[preset], height: ENTITY_NODE_HEIGHT[preset] };
25
+ }
26
+ /** Canonical entity-node sizes — used by every box-based notation preview. */
27
+ export const ENTITY_NODE_SIZE = {
28
+ compact: entityNodeSize('compact'),
29
+ normal: entityNodeSize('normal'),
30
+ wide: entityNodeSize('wide'),
22
31
  };
23
- export const ACTION_NODE_SIZE = {
24
- compact: { width: 180, height: 72 },
25
- normal: { width: 200, height: 80 },
26
- wide: { width: 260, height: 80 },
32
+ /** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
33
+ export const GOALS_NODE_SIZE = ENTITY_NODE_SIZE;
34
+ /** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
35
+ export const DGCA_NODE_SIZE = ENTITY_NODE_SIZE;
36
+ /** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
37
+ export const BLOCKS_LEAF_SIZE = ENTITY_NODE_SIZE;
38
+ /** @deprecated alias — use {@link ENTITY_NODE_SIZE} */
39
+ export const ACTION_NODE_SIZE = ENTITY_NODE_SIZE;
40
+ export const CAPABILITY_MAP_NODE_SIZE = {
41
+ compact: { nodeWidth: ENTITY_NODE_WIDTH.compact, nodeHeight: ENTITY_NODE_HEIGHT.compact },
42
+ normal: { nodeWidth: ENTITY_NODE_WIDTH.normal, nodeHeight: ENTITY_NODE_HEIGHT.normal },
43
+ wide: { nodeWidth: ENTITY_NODE_WIDTH.wide, nodeHeight: ENTITY_NODE_HEIGHT.wide },
27
44
  };
28
- export const PROCESS_BLUEPRINT_SIZE = {
29
- compact: {
30
- legendColumnWidth: 120,
31
- stageColumnWidth: 180,
32
- stageHeaderHeight: 36,
33
- goalRowHeight: 52,
34
- resultRowHeight: 52,
35
- aspectRowMinHeight: 56,
36
- pillHeight: 36,
37
- pillGap: 4,
38
- cellPadding: 8,
39
- textLineHeight: 16,
40
- textCharWidth: 7.5,
41
- cellTextPadX: 10,
42
- cellTextPadY: 10,
43
- },
44
- normal: {
45
- legendColumnWidth: 140,
46
- stageColumnWidth: 220,
47
- stageHeaderHeight: 40,
48
- goalRowHeight: 56,
49
- resultRowHeight: 56,
50
- aspectRowMinHeight: 60,
51
- pillHeight: 40,
52
- pillGap: 4,
53
- cellPadding: 8,
54
- textLineHeight: 17,
55
- textCharWidth: 7.5,
56
- cellTextPadX: 10,
57
- cellTextPadY: 10,
58
- },
59
- wide: {
60
- legendColumnWidth: 160,
61
- stageColumnWidth: 280,
62
- stageHeaderHeight: 44,
63
- goalRowHeight: 60,
64
- resultRowHeight: 60,
65
- aspectRowMinHeight: 64,
66
- pillHeight: 44,
67
- pillGap: 4,
68
- cellPadding: 8,
69
- textLineHeight: 18,
70
- textCharWidth: 7.5,
71
- cellTextPadX: 10,
72
- cellTextPadY: 10,
73
- },
45
+ const PROCESS_BLUEPRINT_NORMAL = {
46
+ legendColumnWidth: 140,
47
+ stageColumnWidth: 220,
48
+ stageHeaderHeight: 40,
49
+ goalRowHeight: 56,
50
+ resultRowHeight: 56,
51
+ aspectRowMinHeight: 60,
52
+ pillHeight: 40,
53
+ pillGap: 4,
54
+ cellPadding: 8,
55
+ textLineHeight: 17,
56
+ textCharWidth: 7.5,
57
+ cellTextPadX: 10,
58
+ cellTextPadY: 10,
74
59
  };
75
- export const CAPABILITY_MAP_NODE_SIZE = {
76
- compact: { nodeWidth: 200, nodeHeight: 56 },
77
- normal: { nodeWidth: 240, nodeHeight: 60 },
78
- wide: { nodeWidth: 300, nodeHeight: 64 },
60
+ function scaleBlueprint(base, widthScale, heightScale) {
61
+ const w = (n) => Math.round(n * widthScale);
62
+ const h = (n) => Math.round(n * heightScale);
63
+ return {
64
+ legendColumnWidth: w(base.legendColumnWidth),
65
+ stageColumnWidth: w(base.stageColumnWidth),
66
+ stageHeaderHeight: h(base.stageHeaderHeight),
67
+ goalRowHeight: h(base.goalRowHeight),
68
+ resultRowHeight: h(base.resultRowHeight),
69
+ aspectRowMinHeight: h(base.aspectRowMinHeight),
70
+ pillHeight: h(base.pillHeight),
71
+ pillGap: base.pillGap,
72
+ cellPadding: base.cellPadding,
73
+ textLineHeight: h(base.textLineHeight),
74
+ textCharWidth: base.textCharWidth,
75
+ cellTextPadX: base.cellTextPadX,
76
+ cellTextPadY: base.cellTextPadY,
77
+ };
78
+ }
79
+ const PB_WIDTH_SCALE = {
80
+ compact: ENTITY_NODE_WIDTH.compact / ENTITY_NODE_WIDTH.normal,
81
+ normal: 1,
82
+ wide: ENTITY_NODE_WIDTH.wide / ENTITY_NODE_WIDTH.normal,
83
+ };
84
+ const PB_HEIGHT_SCALE = {
85
+ compact: ENTITY_NODE_HEIGHT.compact / ENTITY_NODE_HEIGHT.normal,
86
+ normal: 1,
87
+ wide: ENTITY_NODE_HEIGHT.wide / ENTITY_NODE_HEIGHT.normal,
88
+ };
89
+ export const PROCESS_BLUEPRINT_SIZE = {
90
+ compact: scaleBlueprint(PROCESS_BLUEPRINT_NORMAL, PB_WIDTH_SCALE.compact, PB_HEIGHT_SCALE.compact),
91
+ normal: PROCESS_BLUEPRINT_NORMAL,
92
+ wide: scaleBlueprint(PROCESS_BLUEPRINT_NORMAL, PB_WIDTH_SCALE.wide, PB_HEIGHT_SCALE.wide),
79
93
  };
80
94
  /** Parse a VS Code setting value into a preset id (defaults to `normal`). */
81
95
  export function parseNodeSizePreset(value) {
@@ -84,16 +98,16 @@ export function parseNodeSizePreset(value) {
84
98
  return 'normal';
85
99
  }
86
100
  export function resolveGoalsNodeSize(preset) {
87
- return GOALS_NODE_SIZE[preset];
101
+ return ENTITY_NODE_SIZE[preset];
88
102
  }
89
103
  export function resolveDgcaNodeSize(preset) {
90
- return DGCA_NODE_SIZE[preset];
104
+ return ENTITY_NODE_SIZE[preset];
91
105
  }
92
106
  export function resolveBlocksLeafSize(preset) {
93
- return BLOCKS_LEAF_SIZE[preset];
107
+ return ENTITY_NODE_SIZE[preset];
94
108
  }
95
109
  export function resolveActionNodeSize(preset) {
96
- return ACTION_NODE_SIZE[preset];
110
+ return ENTITY_NODE_SIZE[preset];
97
111
  }
98
112
  export function resolveProcessBlueprintSize(preset) {
99
113
  return PROCESS_BLUEPRINT_SIZE[preset];
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { layoutCenteredEntityText, maxCharsForInnerWidth, wrapWords, wrapTextLines, } from '../entity-text-layout.js';
2
+ import { layoutCenteredEntityText, layoutLeafBlockText, layoutHeaderBlockText, LINE_H_PRIMARY, LINE_H_SECONDARY, TEXT_MARGIN_Y, maxCharsForInnerWidth, ROW_GROUP_GAP, wrapWords, wrapTextLines, } from '../entity-text-layout.js';
3
+ import { ENTITY_NODE_SIZE } from '../../node-size-presets.js';
3
4
  describe('entity-text-layout', () => {
4
5
  it('wrapWords respects max lines and truncates with ellipsis', () => {
5
6
  const long = 'one two three four five six seven eight nine ten eleven twelve';
@@ -10,12 +11,29 @@ describe('entity-text-layout', () => {
10
11
  it('maxCharsForInnerWidth scales with inner width', () => {
11
12
  expect(maxCharsForInnerWidth(160, 7)).toBeGreaterThan(maxCharsForInnerWidth(120, 7));
12
13
  });
14
+ it('layoutCenteredEntityText keeps name and type labels separated', () => {
15
+ const specs = layoutCenteredEntityText({
16
+ boxX: 0,
17
+ boxY: 0,
18
+ boxWidth: 250,
19
+ boxHeight: 80,
20
+ name: 'Grow enterprise value',
21
+ type: 'Strategic',
22
+ id: 'GOAL-VALUE-1',
23
+ nameMaxLines: 2,
24
+ idMaxLines: 1,
25
+ });
26
+ const nameY = specs.find((l) => l.cls === 'text-primary').y;
27
+ const typeY = specs.find((l) => l.cls === 'text-secondary').y;
28
+ const minGap = typeY - nameY - LINE_H_PRIMARY / 2 - LINE_H_SECONDARY / 2;
29
+ expect(minGap).toBeGreaterThanOrEqual(ROW_GROUP_GAP - 1);
30
+ });
13
31
  it('layoutCenteredEntityText emits more name capacity on wide boxes', () => {
14
32
  const narrow = layoutCenteredEntityText({
15
33
  boxX: 0,
16
34
  boxY: 0,
17
35
  boxWidth: 200,
18
- boxHeight: 72,
36
+ boxHeight: 80,
19
37
  name: 'Improve customer onboarding experience across regions',
20
38
  id: 'GOAL-001',
21
39
  nameMaxLines: 2,
@@ -25,7 +43,7 @@ describe('entity-text-layout', () => {
25
43
  boxX: 0,
26
44
  boxY: 0,
27
45
  boxWidth: 320,
28
- boxHeight: 72,
46
+ boxHeight: 80,
29
47
  name: 'Improve customer onboarding experience across regions',
30
48
  id: 'GOAL-001',
31
49
  nameMaxLines: 2,
@@ -41,3 +59,97 @@ describe('entity-text-layout', () => {
41
59
  expect(lines.every((l) => l.length <= 10)).toBe(true);
42
60
  });
43
61
  });
62
+ // Approximate half-extent (px) of each text class's glyphs around its centre
63
+ // y — font-size / 2, the same heuristic the render-blocks overlap regression
64
+ // test (#419) uses. Used below to assert text never visually crosses the box
65
+ // border, not just that the *nominal* line-height budget fits.
66
+ const HALF_EXTENT = {
67
+ 'text-primary': 6, // 12px font
68
+ 'text-secondary': 6, // 11px font, rounded up
69
+ 'text-id': 5, // 10px font
70
+ };
71
+ describe('entity text layout — never overflows the box, always keeps padding', () => {
72
+ const PRESETS = Object.entries(ENTITY_NODE_SIZE); // compact/normal/wide
73
+ const NAMES = [
74
+ 'Short',
75
+ 'Achieve full GDPR & NIS2 compliance',
76
+ 'A name so long it would need three or more lines to render in full without any height limit at all',
77
+ ];
78
+ function assertNoOverflowAndPadded(specs, boxTop, boxBottom) {
79
+ for (const s of specs) {
80
+ const half = HALF_EXTENT[s.cls] ?? 6;
81
+ expect(s.y - half).toBeGreaterThanOrEqual(boxTop);
82
+ expect(s.y + half).toBeLessThanOrEqual(boxBottom);
83
+ }
84
+ }
85
+ for (const [presetName, size] of PRESETS) {
86
+ for (const withType of [false, true]) {
87
+ for (const name of NAMES) {
88
+ it(`layoutCenteredEntityText: ${presetName} preset, type=${withType}, name="${name.slice(0, 20)}…" stays inside the box`, () => {
89
+ const boxY = 0;
90
+ const specs = layoutCenteredEntityText({
91
+ boxX: 0,
92
+ boxY,
93
+ boxWidth: size.width,
94
+ boxHeight: size.height,
95
+ name,
96
+ type: withType ? 'Strategic Goal' : undefined,
97
+ id: 'GOAL-EU-COMPLIANCE-1',
98
+ nameMaxLines: 2,
99
+ idMaxLines: 1,
100
+ });
101
+ assertNoOverflowAndPadded(specs, boxY, boxY + size.height);
102
+ });
103
+ }
104
+ }
105
+ }
106
+ it('reproduces the reported case (wide preset, 2-line name + type + long id) with real padding on all sides', () => {
107
+ const size = ENTITY_NODE_SIZE.normal;
108
+ const boxY = 100;
109
+ const specs = layoutCenteredEntityText({
110
+ boxX: 0,
111
+ boxY,
112
+ boxWidth: size.width,
113
+ boxHeight: size.height,
114
+ name: 'Achieve full GDPR & NIS2 compliance',
115
+ type: 'Project Goal',
116
+ id: 'GOAL-EU-COMPLIANCE-1',
117
+ nameMaxLines: 2,
118
+ idMaxLines: 1,
119
+ });
120
+ const nameLines = specs.filter((s) => s.cls === 'text-primary');
121
+ expect(nameLines.length).toBe(2); // normal preset is tall enough to keep both lines
122
+ assertNoOverflowAndPadded(specs, boxY, boxY + size.height);
123
+ // Explicit padding, not just "doesn't cross the border".
124
+ const firstTop = Math.min(...specs.map((s) => s.y - (HALF_EXTENT[s.cls] ?? 6)));
125
+ const lastBottom = Math.max(...specs.map((s) => s.y + (HALF_EXTENT[s.cls] ?? 6)));
126
+ expect(firstTop - boxY).toBeGreaterThanOrEqual(TEXT_MARGIN_Y);
127
+ expect(boxY + size.height - lastBottom).toBeGreaterThanOrEqual(TEXT_MARGIN_Y);
128
+ });
129
+ it('layoutLeafBlockText stays inside the box for compact preset with a long name and id', () => {
130
+ const size = ENTITY_NODE_SIZE.compact;
131
+ const boxY = 0;
132
+ const specs = layoutLeafBlockText({
133
+ boxX: 0,
134
+ boxY,
135
+ boxWidth: size.width,
136
+ boxHeight: size.height,
137
+ name: 'Personal data records that linger after consent withdrawal across many systems and archives',
138
+ id: 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE',
139
+ });
140
+ assertNoOverflowAndPadded(specs, boxY, boxY + size.height);
141
+ });
142
+ it('layoutHeaderBlockText stays inside a short header strip', () => {
143
+ const boxY = 0;
144
+ const headerHeight = 28;
145
+ const specs = layoutHeaderBlockText({
146
+ boxX: 0,
147
+ boxY,
148
+ boxWidth: 250,
149
+ headerHeight,
150
+ name: 'A fairly long container name here',
151
+ id: 'VERY_LONG_CONTAINER_IDENTIFIER',
152
+ });
153
+ assertNoOverflowAndPadded(specs, boxY, boxY + headerHeight);
154
+ });
155
+ });
@@ -9,6 +9,9 @@
9
9
  import { describe, expect, it } from 'vitest';
10
10
  import { renderBlocksSvg } from '../render-blocks.js';
11
11
  import { CHAR_W_PRIMARY as CHAR_W, CHAR_W_ID, TEXT_MARGIN_X } from '../entity-text-layout.js';
12
+ import { ENTITY_NODE_SIZE } from '../../node-size-presets.js';
13
+ const LEAF_W = ENTITY_NODE_SIZE.normal.width;
14
+ const LEAF_H = ENTITY_NODE_SIZE.normal.height;
12
15
  function leafDoc(name, id) {
13
16
  return {
14
17
  notation: 'blocks',
@@ -69,7 +72,7 @@ describe('renderBlocksSvg — leaf labels', () => {
69
72
  }
70
73
  });
71
74
  it('wraps a long ID to at most 2 lines and truncates with …', () => {
72
- const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE_AND_MORE';
75
+ const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE_AND_MORE_EXTRA_TAIL_TO_FORCE_TRUNCATION';
73
76
  const svg = renderBlocksSvg(leafDoc('Short name', longId));
74
77
  const idLines = extractLabels(svg).filter((l) => l.cls === 'text-id');
75
78
  expect(idLines.length).toBeGreaterThan(0);
@@ -78,10 +81,10 @@ describe('renderBlocksSvg — leaf labels', () => {
78
81
  expect(idLines[1].text.endsWith('…')).toBe(true);
79
82
  }
80
83
  });
81
- it('keeps every label inside the 160px leaf width (approximated by CHAR_W * length)', () => {
84
+ it(`keeps every label inside the ${LEAF_W}px leaf width (approximated by CHAR_W * length)`, () => {
82
85
  const svg = renderBlocksSvg(leafDoc('Personal data records that linger after consent withdrawal across many systems', 'PII_RECORDS_WITH_A_FAIRLY_LONG_IDENTIFIER_HERE'));
83
86
  const labels = extractLabels(svg);
84
- const inner = 160 - TEXT_MARGIN_X * 2;
87
+ const inner = LEAF_W - TEXT_MARGIN_X * 2;
85
88
  for (const l of labels) {
86
89
  const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
87
90
  expect(l.text.length * cw).toBeLessThanOrEqual(inner);
@@ -105,8 +108,8 @@ describe('renderBlocksSvg — container headers', () => {
105
108
  it('truncates a long container ID so it does not overflow the block width', () => {
106
109
  const svg = renderBlocksSvg(containerDoc('Short name', 'VERY_LONG_CONTAINER_IDENTIFIER_THAT_WOULD_OTHERWISE_OVERFLOW', 'CHILD', 'Child'));
107
110
  const labels = extractLabels(svg);
108
- // The container width derives from its single 160px child: 160 + 2*12 (padding) = 184.
109
- const containerW = 160 + 24;
111
+ // Container width derives from its single leaf child: leafW + 2×padding.
112
+ const containerW = LEAF_W + 24;
110
113
  const inner = containerW - TEXT_MARGIN_X * 2;
111
114
  for (const l of labels) {
112
115
  const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
@@ -121,7 +124,7 @@ describe('renderBlocksSvg — container headers', () => {
121
124
  const longName = 'Active systems that are erasure-reachable within the standard 30-day operational window';
122
125
  const svg = renderBlocksSvg(containerDoc(longName, 'ACTIVE'));
123
126
  const labels = extractLabels(svg);
124
- const containerW = 160 + 24;
127
+ const containerW = LEAF_W + 24;
125
128
  const inner = containerW - TEXT_MARGIN_X * 2;
126
129
  const primaries = labels.filter((l) => l.cls === 'text-primary');
127
130
  for (const l of primaries) {
@@ -58,6 +58,47 @@ describe('renderGoalsSvg', () => {
58
58
  expect(svg).toContain('width="0"');
59
59
  expect(svg).toContain('height="0"');
60
60
  });
61
+ it('renders name, type, and id on separate lines without overlap', () => {
62
+ const tree = {
63
+ goal_types: [
64
+ { name: 'Strategic', level: 0 },
65
+ { name: 'Objective', level: 1 },
66
+ ],
67
+ goals: [
68
+ {
69
+ id: 1,
70
+ canonical_id: 'GOAL-VALUE-1',
71
+ name: 'Grow enterprise value',
72
+ type: 'Strategic',
73
+ level: 0,
74
+ parent_id: 0,
75
+ },
76
+ {
77
+ id: 2,
78
+ canonical_id: 'GOAL-COST-1',
79
+ name: 'Reduce run cost 20%',
80
+ type: 'Objective',
81
+ level: 1,
82
+ parent_id: 1,
83
+ },
84
+ ],
85
+ };
86
+ const svg = renderGoalsSvg(tree);
87
+ expect(svg).toContain('Grow enterprise value');
88
+ expect(svg).toContain('Strategic');
89
+ expect(svg).toContain('GOAL-VALUE-1');
90
+ const typeMatches = [...svg.matchAll(/class="text-secondary"[^>]*y="(\d+)"/g)];
91
+ const nameMatches = [...svg.matchAll(/class="text-primary"[^>]*y="(\d+)"/g)];
92
+ expect(typeMatches.length).toBeGreaterThan(0);
93
+ for (const type of typeMatches) {
94
+ const typeY = Number(type[1]);
95
+ const nearestName = nameMatches
96
+ .map((m) => Number(m[1]))
97
+ .filter((y) => y < typeY)
98
+ .sort((a, b) => b - a)[0];
99
+ expect(typeY - nearestName).toBeGreaterThanOrEqual(18);
100
+ }
101
+ });
61
102
  it('shows canonical goal ids in node labels when present', () => {
62
103
  const tree = {
63
104
  goal_types: [{ name: 'Strategy', level: 0 }],