@transitrix/diagrams 1.2.0 → 1.4.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.
Files changed (140) hide show
  1. package/dist/__tests__/yaml-normalize.test.js +9 -9
  2. package/dist/activities/__tests__/validate.test.js +77 -74
  3. package/dist/activities/validate.js +5 -12
  4. package/dist/activity-card/__tests__/example.test.js +3 -5
  5. package/dist/activity-card/__tests__/layout.test.js +2 -9
  6. package/dist/activity-card/__tests__/resolver.test.js +25 -0
  7. package/dist/activity-card/__tests__/validate.test.js +29 -18
  8. package/dist/activity-card/layout.js +1 -7
  9. package/dist/activity-card/resolver.js +11 -2
  10. package/dist/activity-card/types.d.ts +1 -1
  11. package/dist/activity-card/validate.js +3 -10
  12. package/dist/actor/__tests__/validate.test.js +6 -0
  13. package/dist/actor/types.d.ts +1 -1
  14. package/dist/actor/types.js +2 -0
  15. package/dist/applications/types.d.ts +7 -0
  16. package/dist/applications/validate.js +18 -0
  17. package/dist/blocks/__tests__/layout.test.js +1 -1
  18. package/dist/blocks/layout.js +1 -1
  19. package/dist/business-service/__tests__/validate.test.d.ts +1 -0
  20. package/dist/business-service/__tests__/validate.test.js +135 -0
  21. package/dist/business-service/index.d.ts +3 -0
  22. package/dist/business-service/index.js +2 -0
  23. package/dist/business-service/types.d.ts +20 -0
  24. package/dist/business-service/types.js +3 -0
  25. package/dist/business-service/validate.d.ts +2 -0
  26. package/dist/business-service/validate.js +74 -0
  27. package/dist/fgca/__tests__/parse-canonical.test.js +24 -11
  28. package/dist/fgca/__tests__/resolver.test.js +7 -7
  29. package/dist/fgca/__tests__/validate.test.js +11 -11
  30. package/dist/fgca/parse-canonical.js +18 -32
  31. package/dist/fgca/preview-layout.js +50 -1
  32. package/dist/fgca/resolver.d.ts +2 -2
  33. package/dist/fgca/resolver.js +5 -5
  34. package/dist/fgca/validate.js +4 -13
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +5 -0
  37. package/dist/integration/__tests__/validate.test.d.ts +1 -0
  38. package/dist/integration/__tests__/validate.test.js +169 -0
  39. package/dist/integration/index.d.ts +2 -0
  40. package/dist/integration/index.js +1 -0
  41. package/dist/integration/types.d.ts +27 -0
  42. package/dist/integration/types.js +5 -0
  43. package/dist/integration/validate.d.ts +2 -0
  44. package/dist/integration/validate.js +94 -0
  45. package/dist/location/__tests__/validate.test.d.ts +1 -0
  46. package/dist/location/__tests__/validate.test.js +107 -0
  47. package/dist/location/index.d.ts +3 -0
  48. package/dist/location/index.js +2 -0
  49. package/dist/location/types.d.ts +20 -0
  50. package/dist/location/types.js +3 -0
  51. package/dist/location/validate.d.ts +2 -0
  52. package/dist/location/validate.js +63 -0
  53. package/dist/node/__tests__/validate.test.d.ts +1 -0
  54. package/dist/node/__tests__/validate.test.js +100 -0
  55. package/dist/node/index.d.ts +2 -0
  56. package/dist/node/index.js +1 -0
  57. package/dist/node/types.d.ts +18 -0
  58. package/dist/node/types.js +3 -0
  59. package/dist/node/validate.d.ts +2 -0
  60. package/dist/node/validate.js +53 -0
  61. package/dist/repo-validate/__tests__/validate-repo.test.js +444 -0
  62. package/dist/repo-validate/validate-repo.js +224 -10
  63. package/dist/technology-service/__tests__/validate.test.d.ts +1 -0
  64. package/dist/technology-service/__tests__/validate.test.js +121 -0
  65. package/dist/technology-service/index.d.ts +2 -0
  66. package/dist/technology-service/index.js +1 -0
  67. package/dist/technology-service/types.d.ts +18 -0
  68. package/dist/technology-service/types.js +3 -0
  69. package/dist/technology-service/validate.d.ts +2 -0
  70. package/dist/technology-service/validate.js +64 -0
  71. package/dist/webview/__tests__/entry-notations.test.js +2 -4
  72. package/dist/webview/__tests__/render-activities.test.d.ts +1 -0
  73. package/dist/webview/__tests__/render-activities.test.js +91 -0
  74. package/dist/webview/__tests__/render-blocks.test.d.ts +1 -0
  75. package/dist/webview/__tests__/render-blocks.test.js +130 -0
  76. package/dist/webview/entry.d.ts +1 -1
  77. package/dist/webview/entry.js +8 -15
  78. package/dist/webview/render-activities.d.ts +12 -0
  79. package/dist/webview/render-activities.js +9 -3
  80. package/dist/webview/render-activity-card.js +2 -3
  81. package/dist/webview/render-blocks.js +97 -18
  82. package/dist/webview/render-fgca.js +1 -1
  83. package/dist/webview/render-goals.js +28 -5
  84. package/package.json +1 -1
  85. package/src/__tests__/yaml-normalize.test.ts +11 -11
  86. package/src/activities/__tests__/validate.test.ts +77 -74
  87. package/src/activities/validate.ts +5 -12
  88. package/src/activity-card/__tests__/example.test.ts +3 -5
  89. package/src/activity-card/__tests__/layout.test.ts +2 -9
  90. package/src/activity-card/__tests__/resolver.test.ts +27 -0
  91. package/src/activity-card/__tests__/validate.test.ts +30 -18
  92. package/src/activity-card/layout.ts +1 -8
  93. package/src/activity-card/resolver.ts +11 -2
  94. package/src/activity-card/types.ts +1 -1
  95. package/src/activity-card/validate.ts +3 -9
  96. package/src/actor/__tests__/validate.test.ts +8 -0
  97. package/src/actor/types.ts +2 -0
  98. package/src/applications/types.ts +8 -0
  99. package/src/applications/validate.ts +20 -0
  100. package/src/blocks/__tests__/layout.test.ts +1 -1
  101. package/src/blocks/layout.ts +1 -1
  102. package/src/business-service/__tests__/validate.test.ts +160 -0
  103. package/src/business-service/index.ts +3 -0
  104. package/src/business-service/types.ts +29 -0
  105. package/src/business-service/validate.ts +82 -0
  106. package/src/fgca/__tests__/parse-canonical.test.ts +25 -11
  107. package/src/fgca/__tests__/resolver.test.ts +7 -7
  108. package/src/fgca/__tests__/validate.test.ts +11 -11
  109. package/src/fgca/parse-canonical.ts +18 -38
  110. package/src/fgca/preview-layout.ts +53 -1
  111. package/src/fgca/resolver.ts +5 -5
  112. package/src/fgca/validate.ts +4 -12
  113. package/src/index.ts +5 -0
  114. package/src/integration/__tests__/validate.test.ts +203 -0
  115. package/src/integration/index.ts +2 -0
  116. package/src/integration/types.ts +38 -0
  117. package/src/integration/validate.ts +104 -0
  118. package/src/location/__tests__/validate.test.ts +127 -0
  119. package/src/location/index.ts +3 -0
  120. package/src/location/types.ts +29 -0
  121. package/src/location/validate.ts +70 -0
  122. package/src/node/__tests__/validate.test.ts +118 -0
  123. package/src/node/index.ts +2 -0
  124. package/src/node/types.ts +27 -0
  125. package/src/node/validate.ts +59 -0
  126. package/src/repo-validate/__tests__/validate-repo.test.ts +550 -0
  127. package/src/repo-validate/validate-repo.ts +233 -10
  128. package/src/technology-service/__tests__/validate.test.ts +143 -0
  129. package/src/technology-service/index.ts +2 -0
  130. package/src/technology-service/types.ts +27 -0
  131. package/src/technology-service/validate.ts +71 -0
  132. package/src/webview/__tests__/entry-notations.test.ts +2 -4
  133. package/src/webview/__tests__/render-activities.test.ts +100 -0
  134. package/src/webview/__tests__/render-blocks.test.ts +155 -0
  135. package/src/webview/entry.ts +10 -19
  136. package/src/webview/render-activities.ts +29 -4
  137. package/src/webview/render-activity-card.ts +2 -3
  138. package/src/webview/render-blocks.ts +101 -20
  139. package/src/webview/render-fgca.ts +1 -1
  140. package/src/webview/render-goals.ts +26 -5
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Unit tests for the host-neutral nested-blocks SVG renderer used by both the
3
+ * VS Code blocks preview and the IntelliJ JCEF webview bundle. These tests pin
4
+ * the per-node label rule (CLAUDE.md "Entity block layout"): leaf nodes wrap
5
+ * the name to at most 3 lines and the ID to at most 2 lines, with `…`
6
+ * truncation beyond that; container headers stack name + ID, each truncated
7
+ * with `…` to fit the block width. No label may extend past the rect.
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+ import { renderBlocksSvg } from '../render-blocks.js';
11
+ // Approximate character widths used by the renderer when sizing labels.
12
+ // Kept in sync with `CHAR_W` / `CHAR_W_ID` in render-blocks.ts so the test
13
+ // can compute the same overflow bounds the renderer uses.
14
+ const CHAR_W = 7;
15
+ const CHAR_W_ID = 6;
16
+ // Same horizontal margin the renderer reserves so text never abuts the rect.
17
+ const TEXT_MARGIN_X = 8;
18
+ function leafDoc(name, id) {
19
+ return {
20
+ notation: 'blocks',
21
+ spec_version: '0.1',
22
+ nested_blocks: {
23
+ id: 'BLOCKS-T-1',
24
+ name: 'Test',
25
+ blocks: [{ id, name }],
26
+ },
27
+ };
28
+ }
29
+ function containerDoc(name, id, childId = 'CHILD', childName = 'Child') {
30
+ return {
31
+ notation: 'blocks',
32
+ spec_version: '0.1',
33
+ nested_blocks: {
34
+ id: 'BLOCKS-T-1',
35
+ name: 'Test',
36
+ blocks: [
37
+ {
38
+ id,
39
+ name,
40
+ children: [{ id: childId, name: childName }],
41
+ },
42
+ ],
43
+ },
44
+ };
45
+ }
46
+ /** Pull every text-primary / text-id line out of an SVG, in document order. */
47
+ function extractLabels(svg) {
48
+ const re = /<text class="(text-(?:primary|id))"[^>]*>([^<]*)<\/text>/g;
49
+ const out = [];
50
+ let m;
51
+ while ((m = re.exec(svg)))
52
+ out.push({ cls: m[1], text: m[2] });
53
+ return out;
54
+ }
55
+ describe('renderBlocksSvg — leaf labels', () => {
56
+ it('wraps a long name to at most 3 lines and truncates with …', () => {
57
+ const longName = 'Personal data records that linger after consent withdrawal across many systems and ' +
58
+ 'archives — known erasure gap';
59
+ const svg = renderBlocksSvg(leafDoc(longName, 'PII'));
60
+ const nameLines = extractLabels(svg).filter((l) => l.cls === 'text-primary');
61
+ expect(nameLines.length).toBeGreaterThan(0);
62
+ expect(nameLines.length).toBeLessThanOrEqual(3);
63
+ // If the name needed truncation, the final visible line must end with `…`.
64
+ if (nameLines.length === 3) {
65
+ expect(nameLines[2].text.endsWith('…')).toBe(true);
66
+ }
67
+ });
68
+ it('wraps a long ID to at most 2 lines and truncates with …', () => {
69
+ const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE_AND_MORE';
70
+ const svg = renderBlocksSvg(leafDoc('Short name', longId));
71
+ const idLines = extractLabels(svg).filter((l) => l.cls === 'text-id');
72
+ expect(idLines.length).toBeGreaterThan(0);
73
+ expect(idLines.length).toBeLessThanOrEqual(2);
74
+ if (idLines.length === 2) {
75
+ expect(idLines[1].text.endsWith('…')).toBe(true);
76
+ }
77
+ });
78
+ it('keeps every label inside the 160px leaf width (approximated by CHAR_W * length)', () => {
79
+ const svg = renderBlocksSvg(leafDoc('Personal data records that linger after consent withdrawal across many systems', 'PII_RECORDS_WITH_A_FAIRLY_LONG_IDENTIFIER_HERE'));
80
+ const labels = extractLabels(svg);
81
+ const inner = 160 - TEXT_MARGIN_X * 2;
82
+ for (const l of labels) {
83
+ const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
84
+ expect(l.text.length * cw).toBeLessThanOrEqual(inner);
85
+ }
86
+ });
87
+ });
88
+ describe('renderBlocksSvg — container headers', () => {
89
+ it('renders the name and ID as two separate stacked text elements', () => {
90
+ const svg = renderBlocksSvg(containerDoc('Active systems', 'ACTIVE_SYSTEMS'));
91
+ const labels = extractLabels(svg);
92
+ // Container header contributes one text-primary (name) and one text-id (id);
93
+ // the leaf child contributes another pair. So 2 of each kind in total.
94
+ const primaries = labels.filter((l) => l.cls === 'text-primary').map((l) => l.text);
95
+ const ids = labels.filter((l) => l.cls === 'text-id').map((l) => l.text);
96
+ expect(primaries).toContain('Active systems');
97
+ expect(ids).toContain('ACTIVE_SYSTEMS');
98
+ // The legacy "(id)" suffix on the name line must not appear — id has its own row.
99
+ for (const t of primaries)
100
+ expect(t).not.toContain('(');
101
+ });
102
+ it('truncates a long container ID so it does not overflow the block width', () => {
103
+ const svg = renderBlocksSvg(containerDoc('Short name', 'VERY_LONG_CONTAINER_IDENTIFIER_THAT_WOULD_OTHERWISE_OVERFLOW', 'CHILD', 'Child'));
104
+ const labels = extractLabels(svg);
105
+ // The container width derives from its single 160px child: 160 + 2*12 (padding) = 184.
106
+ const containerW = 160 + 24;
107
+ const inner = containerW - TEXT_MARGIN_X * 2;
108
+ for (const l of labels) {
109
+ const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
110
+ expect(l.text.length * cw).toBeLessThanOrEqual(inner);
111
+ }
112
+ // The full ID was too long to fit on one line of `inner / CHAR_W_ID` chars, so
113
+ // the rendered ID must be the truncated form ending in `…`.
114
+ const renderedIds = labels.filter((l) => l.cls === 'text-id').map((l) => l.text);
115
+ expect(renderedIds.some((t) => t.endsWith('…'))).toBe(true);
116
+ });
117
+ it('truncates a long container name with … to fit the block width', () => {
118
+ const longName = 'Active systems that are erasure-reachable within the standard 30-day operational window';
119
+ const svg = renderBlocksSvg(containerDoc(longName, 'ACTIVE'));
120
+ const labels = extractLabels(svg);
121
+ const containerW = 160 + 24;
122
+ const inner = containerW - TEXT_MARGIN_X * 2;
123
+ const primaries = labels.filter((l) => l.cls === 'text-primary');
124
+ for (const l of primaries) {
125
+ expect(l.text.length * CHAR_W).toBeLessThanOrEqual(inner);
126
+ }
127
+ // At least one primary line must have been truncated.
128
+ expect(primaries.some((l) => l.text.endsWith('…'))).toBe(true);
129
+ });
130
+ });
@@ -27,7 +27,7 @@ export interface RenderResult {
27
27
  * convention used by the VS Code extension's `activationEvents`. All twelve
28
28
  * are wired to their validator + renderer as of Step 4.
29
29
  */
30
- export type NotationKind = 'goals' | 'dgca' | 'dga' | 'fgca' | 'fga' | 'activities' | 'activity-card' | 'applications' | 'products' | 'process-map' | 'process-blueprint' | 'scenarios' | 'capability-map' | 'blocks';
30
+ export type NotationKind = 'goals' | 'dgca' | 'dga' | 'action' | 'action-card' | 'applications' | 'products' | 'process-map' | 'process-blueprint' | 'scenarios' | 'capability-map' | 'blocks';
31
31
  export declare function render(notationKind: string, source: string): RenderResult;
32
32
  export declare const api: {
33
33
  version: string;
@@ -48,10 +48,8 @@ const SUPPORTED_KINDS = [
48
48
  'goals',
49
49
  'dgca',
50
50
  'dga',
51
- 'fgca',
52
- 'fga',
53
- 'activities',
54
- 'activity-card',
51
+ 'action',
52
+ 'action-card',
55
53
  'applications',
56
54
  'products',
57
55
  'process-map',
@@ -110,11 +108,7 @@ function dispatchValidate(kind, doc) {
110
108
  }
111
109
  return r;
112
110
  }
113
- // DGCA and DGA are the canonical new names; FGCA/FGA are accepted as
114
- // deprecated aliases. All four share the same parse + renderer; only the
115
- // Change column visibility differs (DGA/FGA collapse it).
116
- case 'dgca':
117
- case 'fgca': {
111
+ case 'dgca': {
118
112
  const v = parseCanonicalFGCA(doc);
119
113
  const r = emptyResult(kind, v.valid ? 'ok' : 'error');
120
114
  r.errors.push(...v.errors);
@@ -124,8 +118,7 @@ function dispatchValidate(kind, doc) {
124
118
  }
125
119
  return r;
126
120
  }
127
- case 'dga':
128
- case 'fga': {
121
+ case 'dga': {
129
122
  const v = parseCanonicalFGA(doc);
130
123
  const r = emptyResult(kind, v.valid ? 'ok' : 'error');
131
124
  r.errors.push(...v.errors);
@@ -135,10 +128,10 @@ function dispatchValidate(kind, doc) {
135
128
  }
136
129
  return r;
137
130
  }
138
- case 'activities':
139
- return renderFromValidation('activities', validateActivities(doc), () => renderActivitiesSvg(doc));
140
- case 'activity-card':
141
- return renderFromValidation('activity-card', validateActivityCard(doc), () => renderActivityCardSvg(doc));
131
+ case 'action':
132
+ return renderFromValidation('action', validateActivities(doc), () => renderActivitiesSvg(doc));
133
+ case 'action-card':
134
+ return renderFromValidation('action-card', validateActivityCard(doc), () => renderActivityCardSvg(doc));
142
135
  case 'process-blueprint':
143
136
  return renderFromValidation('process-blueprint', validateProcessBlueprint(doc), () => renderProcessBlueprintSvg(doc));
144
137
  case 'blocks':
@@ -28,6 +28,18 @@ export interface RenderActivitiesOptions {
28
28
  curvature?: number;
29
29
  /** Entry curvature at the target node; defaults to `curvature` when omitted. */
30
30
  entryCurvature?: number;
31
+ /**
32
+ * When true (the default), activities with `activity_type === 'project'` are
33
+ * excluded from the network layout. Project container nodes add visual noise
34
+ * in the PSND view because the diagram itself already represents the project
35
+ * scope; suppressing them declutters the network without altering canonical
36
+ * data. Set to false to render all activities regardless of type.
37
+ *
38
+ * Convention: Network/diagram views suppress project nodes by default.
39
+ * Text/document views (Tree) keep them visible and compensate with the
40
+ * Action name in the view header.
41
+ */
42
+ suppressProjectNodes?: boolean;
31
43
  }
32
44
  /**
33
45
  * Render the network (PSND) view of an already-validated ActivityDoc to a
@@ -132,12 +132,18 @@ export function renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entr
132
132
  * than short-circuiting.
133
133
  */
134
134
  export function renderActivitiesSvg(doc, options = {}) {
135
- const { title = '', gaps = {}, curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
136
- const layout = layoutActivities(doc, gaps);
135
+ const { title = '', gaps = {}, curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, suppressProjectNodes = true, } = options;
136
+ // #421: Suppress project-type container nodes in the network layout by
137
+ // default (see suppressProjectNodes JSDoc). A shallow copy avoids mutating
138
+ // the caller's doc.
139
+ const renderDoc = suppressProjectNodes
140
+ ? { ...doc, activities: (doc.activities ?? []).filter((a) => a.activity_type?.toLowerCase() !== 'project') }
141
+ : doc;
142
+ const layout = layoutActivities(renderDoc, gaps);
137
143
  if (layout.nodes.length === 0) {
138
144
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
139
145
  }
140
- const cpm = computeCpm(doc.activities ?? []);
146
+ const cpm = computeCpm(renderDoc.activities ?? []);
141
147
  const titleH = title ? 24 : 0;
142
148
  const w = layout.bounds.width + N_PAD * 2;
143
149
  const h = layout.bounds.height + N_PAD * 2 + titleH;
@@ -12,7 +12,7 @@
12
12
  * CROSS-DOCUMENT RESOLUTION CAVEAT
13
13
  * The Activity Card is the only MULTI-DOCUMENT Studio notation: the card YAML
14
14
  * names a project Activity, and the project's name, dates, motivation chain
15
- * (Drivers → Assessments → Goals → Changes) and child activities are pulled BY
15
+ * (Drivers → Goals → Changes) and child activities are pulled BY
16
16
  * REFERENCE from the canonical element + relation store (`canon/elements/**`,
17
17
  * `canon/relations/**`; see `../activity-card/resolver.ts`). That resolution
18
18
  * requires reading those files from disk, which is NOT available inside the
@@ -108,10 +108,9 @@ export function renderActivityCardBody(layout, ox, oy) {
108
108
  parts.push(`<text class="text-primary" x="${r.x + ox + 12}" y="${r.y + oy + 44 + i * 18}" style="dominant-baseline:central">${escXml(line)}</text>`);
109
109
  });
110
110
  }
111
- // Chain sections (Drivers → Assessments → Goals → Changes).
111
+ // Chain sections (Drivers → Goals → Changes).
112
112
  const SECTION_LEVEL = {
113
113
  drivers: 4,
114
- assessments: 5,
115
114
  goals: 5,
116
115
  changes: 6,
117
116
  };
@@ -18,8 +18,54 @@ import { escXml } from './render-util.js';
18
18
  const PAD = 24;
19
19
  // Approximate character width (px) at text-primary size (12px/600).
20
20
  const CHAR_W = 7;
21
- // Approximate character width (px) at text-id size (10px/600) — used for ID suffix.
21
+ // Approximate character width (px) at text-id size (10px/600).
22
22
  const CHAR_W_ID = 6;
23
+ // 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
27
+ /** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
28
+ function wrapWords(text, maxChars, maxLines) {
29
+ const safeMax = Math.max(2, maxChars);
30
+ const words = text.split(' ');
31
+ const lines = [];
32
+ let cur = '';
33
+ for (let i = 0; i < words.length; i++) {
34
+ const w = words[i];
35
+ const next = cur ? `${cur} ${w}` : w;
36
+ if (next.length <= safeMax) {
37
+ cur = next;
38
+ }
39
+ else {
40
+ if (cur)
41
+ lines.push(cur);
42
+ cur = '';
43
+ if (lines.length >= maxLines)
44
+ break;
45
+ if (lines.length === maxLines - 1) {
46
+ const rest = words.slice(i).join(' ');
47
+ lines.push(rest.length <= safeMax ? rest : rest.slice(0, safeMax - 1) + '…');
48
+ return lines;
49
+ }
50
+ cur = w.length <= safeMax ? w : w.slice(0, safeMax - 1) + '…';
51
+ }
52
+ }
53
+ if (cur && lines.length < maxLines)
54
+ lines.push(cur);
55
+ return lines.length ? lines : [text.slice(0, safeMax - 1) + '…'];
56
+ }
57
+ /** Split an ID (no spaces) across at most 2 lines, breaking at `_` or `-`. */
58
+ function wrapId(id, maxChars) {
59
+ if (id.length <= maxChars)
60
+ return [id];
61
+ const seg = id.slice(0, maxChars);
62
+ const sepIdx = Math.max(seg.lastIndexOf('_'), seg.lastIndexOf('-'));
63
+ const cut = sepIdx > Math.floor(maxChars / 3) ? sepIdx : maxChars - 1;
64
+ const isSep = id[cut] === '_' || id[cut] === '-';
65
+ const line1 = id.slice(0, cut);
66
+ const rest = id.slice(cut + (isSep ? 1 : 0));
67
+ return [line1, rest.length <= maxChars ? rest : rest.slice(0, maxChars - 1) + '…'];
68
+ }
23
69
  /**
24
70
  * Pick the diagram-frame level class for a block at the given depth.
25
71
  *
@@ -32,34 +78,67 @@ function levelClassForDepth(depth) {
32
78
  const idx = Math.min(Math.max(depth - 1, 0), 6);
33
79
  return `level-${idx}`;
34
80
  }
35
- /** Truncate at a word boundary so labels never break in the middle of a word. */
36
- function wordTruncate(text, maxChars) {
37
- if (text.length <= maxChars)
81
+ // Reserve a small horizontal margin so text never abuts the rounded corners.
82
+ const TEXT_MARGIN_X = 8;
83
+ /** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
84
+ function truncateLine(text, maxChars) {
85
+ const safeMax = Math.max(2, maxChars);
86
+ if (text.length <= safeMax)
38
87
  return text;
39
- const cut = text.slice(0, maxChars - 1);
88
+ const cut = text.slice(0, safeMax - 1);
40
89
  const lastSpace = cut.lastIndexOf(' ');
41
- return (lastSpace > maxChars / 2 ? cut.slice(0, lastSpace) : cut) + '…';
90
+ return (lastSpace > safeMax / 2 ? cut.slice(0, lastSpace) : cut) + '…';
91
+ }
92
+ /** Single-line ID truncation that prefers `_`/`-` separators before falling back to a hard cut. */
93
+ function truncateId(id, maxChars) {
94
+ const safeMax = Math.max(2, maxChars);
95
+ if (id.length <= safeMax)
96
+ return id;
97
+ const cut = id.slice(0, safeMax - 1);
98
+ const sepIdx = Math.max(cut.lastIndexOf('_'), cut.lastIndexOf('-'));
99
+ return (sepIdx > safeMax / 3 ? cut.slice(0, sepIdx) : cut) + '…';
42
100
  }
43
101
  function emitBlockSvg(b, ox, oy, parts) {
44
102
  const cls = levelClassForDepth(b.depth);
45
103
  const cx = b.x + ox + b.width / 2;
46
104
  parts.push(`<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="6"/>`);
105
+ const innerW = Math.max(0, b.width - TEXT_MARGIN_X * 2);
47
106
  const isLeaf = b.children.length === 0;
48
107
  if (isLeaf) {
49
- // Leaf block: name on upper line, ID on lower line — both centred.
50
- const nameY = b.y + oy + Math.round(b.height * 0.38);
51
- const idY = b.y + oy + Math.round(b.height * 0.66);
52
- const maxChars = Math.max(4, Math.floor(b.width / CHAR_W));
53
- parts.push(`<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(wordTruncate(b.name, maxChars))}</text>`, `<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(b.id)}</text>`);
108
+ // Leaf block: name (up to 3 lines) then ID (up to 2 lines), centred vertically.
109
+ const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
110
+ const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
111
+ const nameLines = wrapWords(b.name, maxCharsName, 3);
112
+ const idLines = wrapId(b.id, maxCharsId);
113
+ const nameTotalSpan = (nameLines.length - 1) * LINE_H;
114
+ const idTotalSpan = (idLines.length - 1) * LINE_H_ID;
115
+ const totalSpan = nameTotalSpan + NAME_ID_GAP + idTotalSpan;
116
+ const firstY = Math.round(b.y + oy + (b.height - totalSpan) / 2);
117
+ for (let i = 0; i < nameLines.length; i++) {
118
+ parts.push(`<text class="text-primary" x="${cx}" y="${firstY + i * LINE_H}" text-anchor="middle" dominant-baseline="central">${escXml(nameLines[i])}</text>`);
119
+ }
120
+ const idFirstY = firstY + nameTotalSpan + NAME_ID_GAP;
121
+ for (let i = 0; i < idLines.length; i++) {
122
+ parts.push(`<text class="text-id" x="${cx}" y="${idFirstY + i * LINE_H_ID}" text-anchor="middle" dominant-baseline="central">${escXml(idLines[i])}</text>`);
123
+ }
54
124
  }
55
125
  else {
56
- // Container block: name + (ID) on the single header line; ID in grey.
57
- const headerY = b.y + oy + b.headerHeight / 2;
58
- const idSuffix = ` (${b.id})`;
59
- const idSuffixW = idSuffix.length * CHAR_W_ID;
60
- const nameMaxChars = Math.max(4, Math.floor((b.width - idSuffixW) / CHAR_W));
61
- const nameText = wordTruncate(b.name, nameMaxChars);
62
- parts.push(`<text class="text-primary" x="${cx}" y="${headerY}" text-anchor="middle" dominant-baseline="central">${escXml(nameText)}<tspan fill="var(--ts-text-secondary, #64748b)" font-size="10" font-weight="600">${escXml(idSuffix)}</tspan></text>`);
126
+ // Container block: name (text-primary) above, ID (text-id, grey) below — both
127
+ // single-line, truncated with `…` to fit within `b.width`. The header strip is
128
+ // only `headerHeight` tall (default 28px), which is enough for one line each at
129
+ // the configured text sizes. Stacking the ID as its own text-id element keeps
130
+ // the wrapping/truncation rule consistent with leaves and guarantees no overflow
131
+ // for any combination of name/ID length.
132
+ const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
133
+ const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
134
+ const nameText = truncateLine(b.name, maxCharsName);
135
+ const idText = truncateId(b.id, maxCharsId);
136
+ const headerMid = b.y + oy + b.headerHeight / 2;
137
+ const halfSpan = (LINE_H + LINE_H_ID) / 4;
138
+ const nameY = Math.round(headerMid - halfSpan);
139
+ const idY = Math.round(headerMid + halfSpan);
140
+ parts.push(`<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(nameText)}</text>`);
141
+ parts.push(`<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>`);
63
142
  for (const c of b.children)
64
143
  emitBlockSvg(c, ox, oy, parts);
65
144
  }
@@ -67,7 +67,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature)
67
67
  `<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="8"/>`,
68
68
  `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>`,
69
69
  twoLines
70
- ? `<text class="text-secondary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>`
70
+ ? `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>`
71
71
  : '',
72
72
  `<text class="text-id" x="${n.x + NODE_W / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(entityId)}</text>`,
73
73
  ]
@@ -15,12 +15,11 @@ 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
17
  const NODE_W = 250;
18
- const NODE_H = 60;
18
+ const NODE_H = 72;
19
19
  const RANK_SEP = 100;
20
20
  const NODE_SEP = 24;
21
21
  const PAD = 24;
22
- const LABEL_MAX = 36;
23
- const LABEL_TRIM = 34;
22
+ const LABEL_CHARS = 30;
24
23
  /**
25
24
  * Host-neutral goals renderer (IntelliJ/UI). Lays the tree out with the default
26
25
  * spacing, then delegates the actual SVG emission to {@link renderGoalsLayoutSvg}
@@ -81,10 +80,34 @@ export function renderGoalsLayoutSvg(layout, options = {}) {
81
80
  const y = n.y + oy;
82
81
  const level = n.data.level % 8;
83
82
  const labelText = n.data.name ?? String(n.id);
84
- const label = labelText.length > LABEL_MAX ? labelText.slice(0, LABEL_TRIM) + '…' : labelText;
83
+ const words = labelText.split(' ');
84
+ let line1 = '';
85
+ let line2 = '';
86
+ for (const w of words) {
87
+ if ((line1 + ' ' + w).trim().length <= LABEL_CHARS) {
88
+ line1 = (line1 + ' ' + w).trim();
89
+ }
90
+ else if ((line2 + ' ' + w).trim().length <= LABEL_CHARS) {
91
+ line2 = (line2 + ' ' + w).trim();
92
+ }
93
+ else if (!line2) {
94
+ line2 = w.slice(0, LABEL_CHARS - 2) + '…';
95
+ break;
96
+ }
97
+ }
98
+ const twoLines = line2.length > 0;
99
+ const nameY1 = twoLines ? y + 12 : y + 16;
100
+ const nameY2 = y + 26;
101
+ const typeY = twoLines ? y + 43 : y + 35;
102
+ const idY = twoLines ? y + 59 : y + 55;
103
+ const typeLabel = n.data.type ?? '';
104
+ const idText = String(n.data.id);
85
105
  return `<g>
86
106
  <rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="8"/>
87
- <text class="text-primary" x="${x + n.width / 2}" y="${y + n.height / 2}" text-anchor="middle" dominant-baseline="central">${escXml(label)}</text>
107
+ <text class="text-primary" x="${x + n.width / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>${twoLines ? `
108
+ <text class="text-primary" x="${x + n.width / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : ''}${typeLabel ? `
109
+ <text class="text-secondary" x="${x + n.width / 2}" y="${typeY}" text-anchor="middle" dominant-baseline="central">${escXml(typeLabel)}</text>` : ''}
110
+ <text class="text-id" x="${x + n.width / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>
88
111
  </g>`;
89
112
  })
90
113
  .join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -61,8 +61,8 @@ describe('coerceDatesToIsoStrings', () => {
61
61
  describe('coerceDatesToIsoStrings — js-yaml integration', () => {
62
62
  it('quoted ISO dates are preserved untouched', () => {
63
63
  const text = [
64
- 'notation: activities',
65
- 'activities:',
64
+ 'notation: action',
65
+ 'actions:',
66
66
  ' - id: "A-001"',
67
67
  ' name: "Quoted dates"',
68
68
  ' duration: 3',
@@ -70,18 +70,18 @@ describe('coerceDatesToIsoStrings — js-yaml integration', () => {
70
70
  ' end_date: "2026-06-03"',
71
71
  '',
72
72
  ].join('\n');
73
- const parsed = yaml.load(text) as { activities: { start_date: unknown; end_date: unknown }[] };
73
+ const parsed = yaml.load(text) as { actions: { start_date: unknown; end_date: unknown }[] };
74
74
  coerceDatesToIsoStrings(parsed);
75
- expect(parsed.activities[0].start_date).toBe('2026-06-01');
76
- expect(parsed.activities[0].end_date).toBe('2026-06-03');
75
+ expect(parsed.actions[0].start_date).toBe('2026-06-01');
76
+ expect(parsed.actions[0].end_date).toBe('2026-06-03');
77
77
  const v = validateActivities(parsed);
78
78
  expect(v.valid).toBe(true);
79
79
  });
80
80
 
81
81
  it('bare ISO dates are coerced to ISO strings and pass validation', () => {
82
82
  const text = [
83
- 'notation: activities',
84
- 'activities:',
83
+ 'notation: action',
84
+ 'actions:',
85
85
  ' - id: "A-001"',
86
86
  ' name: "Bare dates"',
87
87
  ' duration: 3',
@@ -89,14 +89,14 @@ describe('coerceDatesToIsoStrings — js-yaml integration', () => {
89
89
  ' end_date: 2026-06-03',
90
90
  '',
91
91
  ].join('\n');
92
- const parsed = yaml.load(text) as { activities: { start_date: unknown; end_date: unknown }[] };
92
+ const parsed = yaml.load(text) as { actions: { start_date: unknown; end_date: unknown }[] };
93
93
  // Without coercion, js-yaml returns Date instances and the validator
94
94
  // would emit ACT-008 because typeof !== 'string'. Sanity-check that
95
95
  // assumption before normalisation.
96
- expect(parsed.activities[0].start_date).toBeInstanceOf(Date);
96
+ expect(parsed.actions[0].start_date).toBeInstanceOf(Date);
97
97
  coerceDatesToIsoStrings(parsed);
98
- expect(parsed.activities[0].start_date).toBe('2026-06-01');
99
- expect(parsed.activities[0].end_date).toBe('2026-06-03');
98
+ expect(parsed.actions[0].start_date).toBe('2026-06-01');
99
+ expect(parsed.actions[0].end_date).toBe('2026-06-03');
100
100
  const v = validateActivities(parsed);
101
101
  expect(v.valid).toBe(true);
102
102
  });