@transitrix/diagrams 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -222,3 +222,42 @@ describe('computeGanttLayout — pinned mode', () => {
222
222
  expect(r.links[0].targetId).toBe('B');
223
223
  });
224
224
  });
225
+ describe('computeGanttLayout — project-type activity suppression (regression for follow-up to #421)', () => {
226
+ // A project-type container activity is classified as a phase and rolls up into
227
+ // a bar spanning the full timeline. buildActivityViews filters project-type
228
+ // activities before calling computeGanttLayout so this bar never reaches the
229
+ // rendered Gantt. These tests confirm: (a) the container bar exists when NOT
230
+ // filtered, and (b) filtering it out still yields correct leaf/phase bars.
231
+ const docWithProjectContainer = {
232
+ notation: 'action',
233
+ title: 'Platform Launch Action',
234
+ project: { start_date: '2026-06-01' },
235
+ activities: [
236
+ { id: 'PROJ-1', name: 'Platform Launch', activity_type: 'project' },
237
+ { id: 'TASK-1', name: 'Design', activity_type: 'task', duration: 3, parent: 'PROJ-1' },
238
+ { id: 'TASK-2', name: 'Implement', activity_type: 'task', duration: 5, parent: 'PROJ-1', predecessors: ['TASK-1'] },
239
+ ],
240
+ };
241
+ it('includes the project container as a phase bar when not pre-filtered', () => {
242
+ const r = assertRenders(computeGanttLayout(docWithProjectContainer));
243
+ const projBar = r.bars.find(b => b.id === 'PROJ-1');
244
+ expect(projBar).toBeDefined();
245
+ expect(projBar.kind).toBe('phase');
246
+ });
247
+ it('still renders leaf tasks correctly when project-type activity is pre-filtered', () => {
248
+ const filteredDoc = {
249
+ ...docWithProjectContainer,
250
+ activities: docWithProjectContainer.activities.filter((a) => a.activity_type?.toLowerCase() !== 'project'),
251
+ };
252
+ const r = assertRenders(computeGanttLayout(filteredDoc));
253
+ // Project container bar must not appear.
254
+ expect(r.bars.find(b => b.id === 'PROJ-1')).toBeUndefined();
255
+ // Leaf tasks must still render with correct dates.
256
+ const t1 = r.bars.find(b => b.id === 'TASK-1');
257
+ const t2 = r.bars.find(b => b.id === 'TASK-2');
258
+ expect(t1).toBeDefined();
259
+ expect(t2).toBeDefined();
260
+ expect(t1.startDate).toBe('2026-06-01');
261
+ expect(t2.startDate).toBe('2026-06-04'); // ES = 3 (after TASK-1)
262
+ });
263
+ });
@@ -52,6 +52,15 @@ function extractLabels(svg) {
52
52
  out.push({ cls: m[1], text: m[2] });
53
53
  return out;
54
54
  }
55
+ /** Extract {class, y} for each text element in SVG document order. */
56
+ function extractTextY(svg) {
57
+ const re = /<text class="(text-(?:primary|id))"[^>]*\by="([^"]+)"/g;
58
+ const out = [];
59
+ let m;
60
+ while ((m = re.exec(svg)))
61
+ out.push({ cls: m[1], y: parseFloat(m[2]) });
62
+ return out;
63
+ }
55
64
  describe('renderBlocksSvg — leaf labels', () => {
56
65
  it('wraps a long name to at most 3 lines and truncates with …', () => {
57
66
  const longName = 'Personal data records that linger after consent withdrawal across many systems and ' +
@@ -128,3 +137,42 @@ describe('renderBlocksSvg — container headers', () => {
128
137
  expect(primaries.some((l) => l.text.endsWith('…'))).toBe(true);
129
138
  });
130
139
  });
140
+ describe('renderBlocksSvg — name/ID vertical overlap regression (follows-up on #419)', () => {
141
+ // Root cause: NAME_ID_GAP was 6 px. text-primary is 12 px tall (±6 px around
142
+ // centre) and text-id is 10 px tall (±5 px around centre). The minimum gap
143
+ // between centres to avoid overlap is 11 px; a gap of 6 caused 5 px of overlap.
144
+ // Fix: NAME_ID_GAP = 14 (3 px visual buffer, matching the gap between name lines).
145
+ const NAME_HALF_H = 6; // half of text-primary font size (12 px)
146
+ const ID_HALF_H = 5; // half of text-id font size (10 px)
147
+ it('leaf node: last name line bottom does not overlap first ID line top (1 name + 1 ID)', () => {
148
+ const svg = renderBlocksSvg(leafDoc('Short', 'BLK-1'));
149
+ const positions = extractTextY(svg);
150
+ const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
151
+ const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
152
+ expect(nameY.length).toBeGreaterThan(0);
153
+ expect(idY.length).toBeGreaterThan(0);
154
+ const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
155
+ const firstIdTop = Math.min(...idY) - ID_HALF_H;
156
+ expect(firstIdTop).toBeGreaterThan(lastNameBottom);
157
+ });
158
+ it('leaf node: no overlap with a 3-word name (2 name lines + 1 ID)', () => {
159
+ const svg = renderBlocksSvg(leafDoc('Three Word Name', 'BLK-001'));
160
+ const positions = extractTextY(svg);
161
+ const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
162
+ const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
163
+ const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
164
+ const firstIdTop = Math.min(...idY) - ID_HALF_H;
165
+ expect(firstIdTop).toBeGreaterThan(lastNameBottom);
166
+ });
167
+ it('leaf node: no overlap with a long name (3 name lines) and wrapped ID (2 lines)', () => {
168
+ const longName = 'Personal data records that linger after consent withdrawal across many systems and archives';
169
+ const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE';
170
+ const svg = renderBlocksSvg(leafDoc(longName, longId));
171
+ const positions = extractTextY(svg);
172
+ const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
173
+ const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
174
+ const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
175
+ const firstIdTop = Math.min(...idY) - ID_HALF_H;
176
+ expect(firstIdTop).toBeGreaterThan(lastNameBottom);
177
+ });
178
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared visual constants for entity-box (block-style) notation renderers.
3
+ *
4
+ * Goals, Nested Blocks, FGCA/DGCA, and any future notation using the same
5
+ * entity-box visual language import their shared style tokens from here.
6
+ * Centralising them prevents the per-renderer drift that caused the Goals/Blocks
7
+ * style mismatch (border radius diverged to rx=6 in Blocks vs rx=8 everywhere
8
+ * else). Change here and every notation updates together.
9
+ */
10
+ /** Border-radius (px) for entity and leaf-block node rectangles. */
11
+ export declare const ENTITY_NODE_RX = 8;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared visual constants for entity-box (block-style) notation renderers.
3
+ *
4
+ * Goals, Nested Blocks, FGCA/DGCA, and any future notation using the same
5
+ * entity-box visual language import their shared style tokens from here.
6
+ * Centralising them prevents the per-renderer drift that caused the Goals/Blocks
7
+ * style mismatch (border radius diverged to rx=6 in Blocks vs rx=8 everywhere
8
+ * else). Change here and every notation updates together.
9
+ */
10
+ /** Border-radius (px) for entity and leaf-block node rectangles. */
11
+ export const ENTITY_NODE_RX = 8;
@@ -15,15 +15,19 @@
15
15
  import { layoutNestedBlocks } from '../blocks/layout.js';
16
16
  import { generateSvgEmbedCss } from '../theme/index.js';
17
17
  import { escXml } from './render-util.js';
18
+ import { ENTITY_NODE_RX } from './notation-style.js';
18
19
  const PAD = 24;
19
20
  // Approximate character width (px) at text-primary size (12px/600).
20
21
  const CHAR_W = 7;
21
22
  // Approximate character width (px) at text-id size (10px/600).
22
23
  const CHAR_W_ID = 6;
23
24
  // Vertical spacing between line centres.
24
- const LINE_H = 14; // name lines (text-primary)
25
- const LINE_H_ID = 12; // id lines (text-id)
26
- const NAME_ID_GAP = 6; // gap between last name centre and first ID centre
25
+ const LINE_H = 14; // name lines (text-primary, 12 px font)
26
+ const LINE_H_ID = 12; // id lines (text-id, 10 px font)
27
+ // Minimum gap to avoid overlap: half of name text height (6 px) + half of id
28
+ // text height (5 px) = 11 px. Use 14 px to match the name-line separation and
29
+ // leave a 3 px visual buffer — same as the gap between consecutive name lines.
30
+ const NAME_ID_GAP = 14;
27
31
  /** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
28
32
  function wrapWords(text, maxChars, maxLines) {
29
33
  const safeMax = Math.max(2, maxChars);
@@ -101,7 +105,7 @@ function truncateId(id, maxChars) {
101
105
  function emitBlockSvg(b, ox, oy, parts) {
102
106
  const cls = levelClassForDepth(b.depth);
103
107
  const cx = b.x + ox + b.width / 2;
104
- parts.push(`<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="6"/>`);
108
+ parts.push(`<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="${ENTITY_NODE_RX}"/>`);
105
109
  const innerW = Math.max(0, b.width - TEXT_MARGIN_X * 2);
106
110
  const isLeaf = b.children.length === 0;
107
111
  if (isLeaf) {
@@ -14,6 +14,7 @@ import { layoutGoalTree } from '../goals/layout.js';
14
14
  import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
15
15
  import { generateSvgEmbedCss } from '../theme/index.js';
16
16
  import { escXml } from './render-util.js';
17
+ import { ENTITY_NODE_RX } from './notation-style.js';
17
18
  const NODE_W = 250;
18
19
  const NODE_H = 72;
19
20
  const RANK_SEP = 100;
@@ -103,7 +104,7 @@ export function renderGoalsLayoutSvg(layout, options = {}) {
103
104
  const typeLabel = n.data.type ?? '';
104
105
  const idText = String(n.data.id);
105
106
  return `<g>
106
- <rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="8"/>
107
+ <rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="${ENTITY_NODE_RX}"/>
107
108
  <text class="text-primary" x="${x + n.width / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>${twoLines ? `
108
109
  <text class="text-primary" x="${x + n.width / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : ''}${typeLabel ? `
109
110
  <text class="text-secondary" x="${x + n.width / 2}" y="${typeY}" text-anchor="middle" dominant-baseline="central">${escXml(typeLabel)}</text>` : ''}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -243,3 +243,48 @@ describe('computeGanttLayout — pinned mode', () => {
243
243
  expect(r.links[0].targetId).toBe('B');
244
244
  });
245
245
  });
246
+
247
+ describe('computeGanttLayout — project-type activity suppression (regression for follow-up to #421)', () => {
248
+ // A project-type container activity is classified as a phase and rolls up into
249
+ // a bar spanning the full timeline. buildActivityViews filters project-type
250
+ // activities before calling computeGanttLayout so this bar never reaches the
251
+ // rendered Gantt. These tests confirm: (a) the container bar exists when NOT
252
+ // filtered, and (b) filtering it out still yields correct leaf/phase bars.
253
+
254
+ const docWithProjectContainer: ActivityDoc = {
255
+ notation: 'action',
256
+ title: 'Platform Launch Action',
257
+ project: { start_date: '2026-06-01' },
258
+ activities: [
259
+ { id: 'PROJ-1', name: 'Platform Launch', activity_type: 'project' },
260
+ { id: 'TASK-1', name: 'Design', activity_type: 'task', duration: 3, parent: 'PROJ-1' },
261
+ { id: 'TASK-2', name: 'Implement', activity_type: 'task', duration: 5, parent: 'PROJ-1', predecessors: ['TASK-1'] },
262
+ ],
263
+ };
264
+
265
+ it('includes the project container as a phase bar when not pre-filtered', () => {
266
+ const r = assertRenders(computeGanttLayout(docWithProjectContainer));
267
+ const projBar = r.bars.find(b => b.id === 'PROJ-1');
268
+ expect(projBar).toBeDefined();
269
+ expect(projBar!.kind).toBe('phase');
270
+ });
271
+
272
+ it('still renders leaf tasks correctly when project-type activity is pre-filtered', () => {
273
+ const filteredDoc: ActivityDoc = {
274
+ ...docWithProjectContainer,
275
+ activities: docWithProjectContainer.activities.filter(
276
+ (a) => a.activity_type?.toLowerCase() !== 'project',
277
+ ),
278
+ };
279
+ const r = assertRenders(computeGanttLayout(filteredDoc));
280
+ // Project container bar must not appear.
281
+ expect(r.bars.find(b => b.id === 'PROJ-1')).toBeUndefined();
282
+ // Leaf tasks must still render with correct dates.
283
+ const t1 = r.bars.find(b => b.id === 'TASK-1')!;
284
+ const t2 = r.bars.find(b => b.id === 'TASK-2')!;
285
+ expect(t1).toBeDefined();
286
+ expect(t2).toBeDefined();
287
+ expect(t1.startDate).toBe('2026-06-01');
288
+ expect(t2.startDate).toBe('2026-06-04'); // ES = 3 (after TASK-1)
289
+ });
290
+ });
@@ -59,6 +59,15 @@ function extractLabels(svg: string): Array<{ cls: string; text: string }> {
59
59
  return out;
60
60
  }
61
61
 
62
+ /** Extract {class, y} for each text element in SVG document order. */
63
+ function extractTextY(svg: string): Array<{ cls: string; y: number }> {
64
+ const re = /<text class="(text-(?:primary|id))"[^>]*\by="([^"]+)"/g;
65
+ const out: Array<{ cls: string; y: number }> = [];
66
+ let m: RegExpExecArray | null;
67
+ while ((m = re.exec(svg))) out.push({ cls: m[1], y: parseFloat(m[2]) });
68
+ return out;
69
+ }
70
+
62
71
  describe('renderBlocksSvg — leaf labels', () => {
63
72
  it('wraps a long name to at most 3 lines and truncates with …', () => {
64
73
  const longName =
@@ -153,3 +162,48 @@ describe('renderBlocksSvg — container headers', () => {
153
162
  expect(primaries.some((l) => l.text.endsWith('…'))).toBe(true);
154
163
  });
155
164
  });
165
+
166
+ describe('renderBlocksSvg — name/ID vertical overlap regression (follows-up on #419)', () => {
167
+ // Root cause: NAME_ID_GAP was 6 px. text-primary is 12 px tall (±6 px around
168
+ // centre) and text-id is 10 px tall (±5 px around centre). The minimum gap
169
+ // between centres to avoid overlap is 11 px; a gap of 6 caused 5 px of overlap.
170
+ // Fix: NAME_ID_GAP = 14 (3 px visual buffer, matching the gap between name lines).
171
+
172
+ const NAME_HALF_H = 6; // half of text-primary font size (12 px)
173
+ const ID_HALF_H = 5; // half of text-id font size (10 px)
174
+
175
+ it('leaf node: last name line bottom does not overlap first ID line top (1 name + 1 ID)', () => {
176
+ const svg = renderBlocksSvg(leafDoc('Short', 'BLK-1'));
177
+ const positions = extractTextY(svg);
178
+ const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
179
+ const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
180
+ expect(nameY.length).toBeGreaterThan(0);
181
+ expect(idY.length).toBeGreaterThan(0);
182
+ const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
183
+ const firstIdTop = Math.min(...idY) - ID_HALF_H;
184
+ expect(firstIdTop).toBeGreaterThan(lastNameBottom);
185
+ });
186
+
187
+ it('leaf node: no overlap with a 3-word name (2 name lines + 1 ID)', () => {
188
+ const svg = renderBlocksSvg(leafDoc('Three Word Name', 'BLK-001'));
189
+ const positions = extractTextY(svg);
190
+ const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
191
+ const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
192
+ const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
193
+ const firstIdTop = Math.min(...idY) - ID_HALF_H;
194
+ expect(firstIdTop).toBeGreaterThan(lastNameBottom);
195
+ });
196
+
197
+ it('leaf node: no overlap with a long name (3 name lines) and wrapped ID (2 lines)', () => {
198
+ const longName =
199
+ 'Personal data records that linger after consent withdrawal across many systems and archives';
200
+ const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE';
201
+ const svg = renderBlocksSvg(leafDoc(longName, longId));
202
+ const positions = extractTextY(svg);
203
+ const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
204
+ const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
205
+ const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
206
+ const firstIdTop = Math.min(...idY) - ID_HALF_H;
207
+ expect(firstIdTop).toBeGreaterThan(lastNameBottom);
208
+ });
209
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared visual constants for entity-box (block-style) notation renderers.
3
+ *
4
+ * Goals, Nested Blocks, FGCA/DGCA, and any future notation using the same
5
+ * entity-box visual language import their shared style tokens from here.
6
+ * Centralising them prevents the per-renderer drift that caused the Goals/Blocks
7
+ * style mismatch (border radius diverged to rx=6 in Blocks vs rx=8 everywhere
8
+ * else). Change here and every notation updates together.
9
+ */
10
+
11
+ /** Border-radius (px) for entity and leaf-block node rectangles. */
12
+ export const ENTITY_NODE_RX = 8;
@@ -16,6 +16,7 @@ import { layoutNestedBlocks } from '../blocks/layout.js';
16
16
  import type { BlocksFile, BlocksLayout, LaidOutBlock } from '../blocks/types.js';
17
17
  import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
18
18
  import { escXml } from './render-util.js';
19
+ import { ENTITY_NODE_RX } from './notation-style.js';
19
20
 
20
21
  const PAD = 24;
21
22
 
@@ -25,9 +26,12 @@ const CHAR_W = 7;
25
26
  const CHAR_W_ID = 6;
26
27
 
27
28
  // Vertical spacing between line centres.
28
- const LINE_H = 14; // name lines (text-primary)
29
- const LINE_H_ID = 12; // id lines (text-id)
30
- const NAME_ID_GAP = 6; // gap between last name centre and first ID centre
29
+ const LINE_H = 14; // name lines (text-primary, 12 px font)
30
+ const LINE_H_ID = 12; // id lines (text-id, 10 px font)
31
+ // Minimum gap to avoid overlap: half of name text height (6 px) + half of id
32
+ // text height (5 px) = 11 px. Use 14 px to match the name-line separation and
33
+ // leave a 3 px visual buffer — same as the gap between consecutive name lines.
34
+ const NAME_ID_GAP = 14;
31
35
 
32
36
  /** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
33
37
  function wrapWords(text: string, maxChars: number, maxLines: number): string[] {
@@ -110,7 +114,7 @@ function emitBlockSvg(b: LaidOutBlock, ox: number, oy: number, parts: string[]):
110
114
  const cls = levelClassForDepth(b.depth);
111
115
  const cx = b.x + ox + b.width / 2;
112
116
  parts.push(
113
- `<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="6"/>`,
117
+ `<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="${ENTITY_NODE_RX}"/>`,
114
118
  );
115
119
 
116
120
  const innerW = Math.max(0, b.width - TEXT_MARGIN_X * 2);
@@ -15,6 +15,7 @@ import type { GoalTree, GoalTreeLayout, LaidOutEdge } from '../goals/types.js';
15
15
  import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
16
16
  import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
17
17
  import { escXml } from './render-util.js';
18
+ import { ENTITY_NODE_RX } from './notation-style.js';
18
19
 
19
20
  const NODE_W = 250;
20
21
  const NODE_H = 72;
@@ -130,7 +131,7 @@ export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoal
130
131
  const typeLabel = n.data.type ?? '';
131
132
  const idText = String(n.data.id);
132
133
  return `<g>
133
- <rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="8"/>
134
+ <rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="${ENTITY_NODE_RX}"/>
134
135
  <text class="text-primary" x="${x + n.width / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>${twoLines ? `
135
136
  <text class="text-primary" x="${x + n.width / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : ''}${typeLabel ? `
136
137
  <text class="text-secondary" x="${x + n.width / 2}" y="${typeY}" text-anchor="middle" dominant-baseline="central">${escXml(typeLabel)}</text>` : ''}