@transitrix/diagrams 1.2.0 → 1.3.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.
@@ -61,13 +61,11 @@ describe('activity-card worked example (eu-programme)', () => {
61
61
  // Description row
62
62
  expect(layout.descriptionRow?.label).toBe('Description');
63
63
  // Chain sections
64
- expect(layout.chainSections.map((s) => s.type)).toEqual(['drivers', 'assessments', 'goals', 'changes']);
64
+ expect(layout.chainSections.map((s) => s.type)).toEqual(['drivers', 'goals', 'changes']);
65
65
  const driversSection = layout.chainSections.find((s) => s.type === 'drivers');
66
66
  const goalsSection = layout.chainSections.find((s) => s.type === 'goals');
67
- const assessmentsSection = layout.chainSections.find((s) => s.type === 'assessments');
68
67
  expect(driversSection.nodes.map((n) => n.id)).toContain('FACTOR-EU-MDR-1');
69
68
  expect(goalsSection.nodes.map((n) => n.id)).toContain('GOAL-EU-MARKET-1');
70
- expect(assessmentsSection.isEmpty).toBe(true); // no assessment in this fixture
71
69
  // Chain edges
72
70
  expect(layout.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'GOAL-EU-MARKET-1' });
73
71
  expect(layout.chainEdges).toContainEqual({ sourceId: 'GOAL-EU-MARKET-1', targetId: 'CHANGE-EU-COMPLIANCE-1' });
@@ -82,19 +82,16 @@ describe('layoutActivityCard', () => {
82
82
  const l = layoutActivityCard({ ...RESOLVED, cardDescription: undefined });
83
83
  expect(l.descriptionRow).toBeUndefined();
84
84
  });
85
- it('four chain sections in order: drivers, assessments, goals, changes', () => {
85
+ it('three chain sections in order: drivers, goals, changes', () => {
86
86
  const l = layoutActivityCard(RESOLVED);
87
- expect(l.chainSections.map((s) => s.type)).toEqual(['drivers', 'assessments', 'goals', 'changes']);
87
+ expect(l.chainSections.map((s) => s.type)).toEqual(['drivers', 'goals', 'changes']);
88
88
  });
89
89
  it('chain sections carry the resolved nodes', () => {
90
90
  const l = layoutActivityCard(RESOLVED);
91
91
  const drivers = l.chainSections.find((s) => s.type === 'drivers');
92
- const assessments = l.chainSections.find((s) => s.type === 'assessments');
93
92
  const goals = l.chainSections.find((s) => s.type === 'goals');
94
93
  const changes = l.chainSections.find((s) => s.type === 'changes');
95
94
  expect(drivers.nodes.map((n) => n.id)).toEqual(['FACTOR-EU-MDR-1']);
96
- expect(assessments.nodes.map((n) => n.id)).toEqual(['ASSESSMENT-MDR-1']);
97
- expect(assessments.nodes[0].meta).toBe('2026-03-01');
98
95
  expect(goals.nodes.map((n) => n.id)).toEqual(['GOAL-EU-MARKET-1']);
99
96
  expect(changes.nodes.map((n) => n.id)).toEqual(['CHANGE-EU-COMPLIANCE-1']);
100
97
  });
@@ -111,10 +108,6 @@ describe('layoutActivityCard', () => {
111
108
  expect(l.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'GOAL-EU-MARKET-1' });
112
109
  expect(l.chainEdges).toContainEqual({ sourceId: 'GOAL-EU-MARKET-1', targetId: 'CHANGE-EU-COMPLIANCE-1' });
113
110
  });
114
- it('builds driver→assessment edge when assessment is linked', () => {
115
- const l = layoutActivityCard(RESOLVED);
116
- expect(l.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'ASSESSMENT-MDR-1' });
117
- });
118
111
  it('milestone ArchiMate class is set', () => {
119
112
  const l = layoutActivityCard(RESOLVED);
120
113
  expect(l.milestones).toHaveLength(1);
@@ -198,6 +198,31 @@ describe('resolveActivityCard', () => {
198
198
  expect(r.resolved.stakeholders).toEqual([{ id: 'STAKEHOLDER-OPS-1', name: 'STAKEHOLDER-OPS-1', role: 'sponsor' }]);
199
199
  expect(r.warnings.some((w) => w.code === 'PC-001')).toBe(true);
200
200
  });
201
+ it('resolves stakeholders from inline stakeholders: [] on the Action element', () => {
202
+ const projectWithInlineStakeholders = { ...PROJECT, stakeholders: ['STAKEHOLDER-OPS-1'] };
203
+ const r = resolveActivityCard(CARD, {
204
+ elements: [projectWithInlineStakeholders, CHILD, FACTOR, GOAL, CHANGE, STAKEHOLDER],
205
+ relations: [REL_GOAL],
206
+ });
207
+ expect(r.valid).toBe(true);
208
+ const sh = r.resolved.stakeholders;
209
+ expect(sh).toHaveLength(1);
210
+ expect(sh[0].id).toBe('STAKEHOLDER-OPS-1');
211
+ expect(sh[0].name).toBe('Operations');
212
+ expect(sh[0].role).toBeUndefined();
213
+ });
214
+ it('REL stakeholder takes precedence over inline duplicate (carries role)', () => {
215
+ const projectWithInlineStakeholders = { ...PROJECT, stakeholders: ['STAKEHOLDER-OPS-1'] };
216
+ const r = resolveActivityCard(CARD, {
217
+ elements: [projectWithInlineStakeholders, CHILD, FACTOR, GOAL, CHANGE, STAKEHOLDER],
218
+ relations: [REL_GOAL, REL_STAKEHOLDER],
219
+ });
220
+ expect(r.valid).toBe(true);
221
+ const sh = r.resolved.stakeholders;
222
+ // deduplicated — REL entry wins, inline duplicate is dropped
223
+ expect(sh).toHaveLength(1);
224
+ expect(sh[0].role).toBe('sponsor');
225
+ });
201
226
  it('falls back to the inline goals field when no activity_goal relation exists', () => {
202
227
  const projectWithInline = { ...PROJECT, goals: ['GOAL-EU-MARKET-1'] };
203
228
  const r = resolveActivityCard(CARD, {
@@ -164,7 +164,7 @@ export function layoutActivityCard(card, options) {
164
164
  descriptionRow = { label: 'Description', valueLines: lines, x: contentX, y: cursorY, width: contentW, height };
165
165
  cursorY += height + SECTION_GAP;
166
166
  }
167
- // ── 5. Chain: Drivers → Assessments → Goals → Changes ────────────────────
167
+ // ── 5. Chain: Drivers → Goals → Changes ──────────────────────────────────
168
168
  // Each section is a full-width band with a header label and stacked node rows.
169
169
  // An empty section shows a gap indicator ("— not on file") so the author
170
170
  // sees which parts of the narrative are missing.
@@ -192,9 +192,7 @@ export function layoutActivityCard(card, options) {
192
192
  cursorY += sectionH + CONNECTOR_H;
193
193
  }
194
194
  const { drivers, goals, changes } = card.motivation;
195
- const assessments = card.assessments ?? [];
196
195
  pushChainSection('drivers', 'Drivers', 'What prompted this initiative?', drivers.map((d) => ({ id: d.id, name: d.name })));
197
- pushChainSection('assessments', 'Assessments', 'What is the current gap?', assessments.map((a) => ({ id: a.id, name: a.name, meta: a.observed_at })));
198
196
  pushChainSection('goals', 'Goals', 'What outcome do we target?', goals.map((g) => ({ id: g.id, name: g.name })));
199
197
  pushChainSection('changes', 'Changes', 'What will change?', changes.map((c) => ({ id: c.id, name: c.name })));
200
198
  // Remove trailing CONNECTOR_H — last section connects to milestones with SECTION_GAP.
@@ -202,10 +200,6 @@ export function layoutActivityCard(card, options) {
202
200
  // Node-level chain edges.
203
201
  const driverIdSet = new Set(drivers.map((d) => d.id));
204
202
  const goalIdSet = new Set(goals.map((g) => g.id));
205
- for (const a of assessments) {
206
- if (driverIdSet.has(a.driverId))
207
- chainEdges.push({ sourceId: a.driverId, targetId: a.id });
208
- }
209
203
  for (const g of goals) {
210
204
  for (const did of g.driverIds) {
211
205
  if (driverIdSet.has(did))
@@ -350,10 +350,19 @@ export function resolveActivityCard(doc, sources) {
350
350
  // goals referenced only by in-scope changes). Resolve each id to its GOAL
351
351
  // element name, falling back to the id when the element is absent.
352
352
  const goalNames = projectGoalIds.map((gid) => str(goalElems.get(gid)?.['name']) ?? gid);
353
- // ── Stakeholders — active `activity_stakeholder` relations from the project ──
353
+ // ── Stakeholders — inline `stakeholders: []` on the Action element PLUS active
354
+ // `action_stakeholder` / `activity_stakeholder` REL files. Both sources are
355
+ // merged; REL entries take precedence (they carry the role field).
354
356
  const stakeholderElems = collectByNotation(sources.elements, 'stakeholder');
357
+ const relStakeholderRefs = activeActivityStakeholders(sources.relations, projectId);
358
+ const relStakeholderIds = new Set(relStakeholderRefs.map((s) => s.id));
359
+ const inlineStakeholderIds = strArray(projectRec['stakeholders']).filter((id) => !relStakeholderIds.has(id));
360
+ const allStakeholderRefs = [
361
+ ...relStakeholderRefs,
362
+ ...inlineStakeholderIds.map((id) => ({ id })),
363
+ ];
355
364
  const stakeholders = [];
356
- for (const { id: sid, role } of activeActivityStakeholders(sources.relations, projectId)) {
365
+ for (const { id: sid, role } of allStakeholderRefs) {
357
366
  const rec = stakeholderElems.get(sid);
358
367
  if (!rec) {
359
368
  warnings.push({
@@ -214,7 +214,7 @@ export interface ChainEdge {
214
214
  * so the author sees the missing data.
215
215
  */
216
216
  export interface ChainSectionLayout {
217
- type: 'drivers' | 'assessments' | 'goals' | 'changes';
217
+ type: 'drivers' | 'goals' | 'changes';
218
218
  /** Section header label. */
219
219
  label: string;
220
220
  /** Short question answered by this section, shown in grey after the label. */
@@ -143,9 +143,22 @@ describe('parseCanonicalFGCA — edge-driving fields populated', () => {
143
143
  expect(parsed.goals[0].factor).toHaveLength(1);
144
144
  // goal → change edge
145
145
  expect(parsed.changes[0].goal_id).not.toBe(0);
146
+ expect(parsed.changes[0].goal_id).not.toBe('');
146
147
  // change → activity edge
147
148
  expect(parsed.changes[0].activity_ids).toHaveLength(1);
148
149
  });
150
+ it('preserves canonical string IDs — not converted to sequential numbers', () => {
151
+ const r = parseCanonicalFGCA(VALID);
152
+ expect(r.valid).toBe(true);
153
+ const parsed = r.parsed;
154
+ expect(parsed.factors[0].id).toBe('FACTOR-1');
155
+ expect(parsed.goals[0].id).toBe('GOAL-1');
156
+ expect(parsed.goals[0].factor[0].id).toBe('FACTOR-1');
157
+ expect(parsed.changes[0].id).toBe('CHANGE-1');
158
+ expect(parsed.changes[0].goal_id).toBe('GOAL-1');
159
+ expect(parsed.changes[0].activity_ids[0]).toBe('ACTIVITY-1');
160
+ expect(parsed.activities[0].id).toBe('ACTIVITY-1');
161
+ });
149
162
  });
150
163
  describe('parseCanonicalFGA', () => {
151
164
  const VALID_FGA = {
@@ -218,7 +231,7 @@ describe('parseCanonicalFGA — example file regression', () => {
218
231
  expect(r.errors).toEqual([]);
219
232
  expect(r.valid).toBe(true);
220
233
  // Every activity must carry a goal_id, or the FGA preview draws no edges.
221
- expect(r.parsed.activities.every((a) => a.goal_id != null && a.goal_id !== 0)).toBe(true);
234
+ expect(r.parsed.activities.every((a) => a.goal_id != null && a.goal_id !== 0 && a.goal_id !== '')).toBe(true);
222
235
  });
223
236
  }
224
237
  });
@@ -179,29 +179,19 @@ export function parseCanonicalFGCA(input) {
179
179
  }
180
180
  return out;
181
181
  }
182
- // Build internal id-maps (canonical string ID → internal sequential number).
183
- const idMap = { toInternal: new Map() };
184
- const allCanonicalIds = [...factorIds, ...goalIds, ...changeIds, ...activityIds];
185
- allCanonicalIds.forEach((id, i) => idMap.toInternal.set(id, i + 1));
186
- function intId(canonical) {
187
- const n = idMap.toInternal.get(canonical);
188
- if (n === undefined) {
189
- // Shouldn't happen — we built the map from the same sets.
190
- throw new Error(`internal: canonical id "${canonical}" not mapped`);
191
- }
192
- return n;
193
- }
194
- // Layer entries (internal form).
182
+ // Layer entries (internal form). Canonical string IDs are passed through
183
+ // directly — no conversion to sequential numbers. The layout and renderer
184
+ // treat IDs as opaque keys and also display them verbatim to the user.
195
185
  const internalFactors = factors.map((el) => ({
196
- id: intId(String(el['id'])),
186
+ id: String(el['id']),
197
187
  name: String(el['name'] ?? ''),
198
188
  }));
199
189
  const internalGoals = goals.map((el, i) => {
200
190
  const refIds = checkRefArray(el, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`);
201
191
  return {
202
- id: intId(String(el['id'])),
192
+ id: String(el['id']),
203
193
  name: String(el['name'] ?? ''),
204
- factor: refIds.map((r) => ({ id: intId(r) })),
194
+ factor: refIds.map((r) => ({ id: r })),
205
195
  };
206
196
  });
207
197
  // Build a reverse mapping: which changes does each activity reference (via
@@ -234,19 +224,19 @@ export function parseCanonicalFGCA(input) {
234
224
  const canonicalChangeId = String(el['id']);
235
225
  const linkedActivityIds = activitiesForChange.get(canonicalChangeId) ?? [];
236
226
  return {
237
- id: intId(canonicalChangeId),
227
+ id: canonicalChangeId,
238
228
  name: String(el['name'] ?? ''),
239
- goal_id: goalRefs.length > 0 ? intId(goalRefs[0]) : 0,
240
- activity_ids: linkedActivityIds.map((aid) => intId(aid)),
229
+ goal_id: goalRefs.length > 0 ? goalRefs[0] : '',
230
+ activity_ids: linkedActivityIds,
241
231
  };
242
232
  });
243
233
  const internalActivities = activities.map((el, i) => {
244
234
  const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`);
245
235
  const typeVal = typeof el['type'] === 'string' ? el['type'] : undefined;
246
236
  return {
247
- id: intId(String(el['id'])),
237
+ id: String(el['id']),
248
238
  name: String(el['name'] ?? ''),
249
- goal_id: goalRefs.length > 0 ? intId(goalRefs[0]) : null,
239
+ goal_id: goalRefs.length > 0 ? goalRefs[0] : null,
250
240
  ...(typeVal !== undefined ? { type: typeVal } : {}),
251
241
  };
252
242
  });
@@ -61,18 +61,67 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
61
61
  change: changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
62
62
  activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name })),
63
63
  };
64
+ // Build predecessor map: for each node, which node IDs in the previous column
65
+ // connect to it from the left. Used for barycenter crossing minimization.
66
+ const predecessors = new Map();
67
+ for (const g of doc.goals) {
68
+ predecessors.set(`goal_${g.id}`, (g.factor ?? []).map(f => `driver_${f.id}`));
69
+ }
70
+ if (!hideChanges) {
71
+ for (const c of changes) {
72
+ predecessors.set(`change_${c.id}`, [`goal_${c.goal_id}`]);
73
+ }
74
+ const coveredActs = new Set(changes.flatMap(c => c.activity_ids.map(String)));
75
+ for (const a of doc.activities) {
76
+ const preds = changes
77
+ .filter(c => c.activity_ids.map(String).includes(String(a.id)))
78
+ .map(c => `change_${c.id}`);
79
+ if (a.goal_id != null && !coveredActs.has(String(a.id)))
80
+ preds.push(`goal_${a.goal_id}`);
81
+ predecessors.set(`activity_${a.id}`, preds);
82
+ }
83
+ }
84
+ else {
85
+ const connectedViaChange = new Set();
86
+ for (const c of changes) {
87
+ for (const aid of c.activity_ids) {
88
+ predecessors.set(`activity_${aid}`, [`goal_${c.goal_id}`]);
89
+ connectedViaChange.add(String(aid));
90
+ }
91
+ }
92
+ for (const a of doc.activities) {
93
+ if (a.goal_id != null && !connectedViaChange.has(String(a.id))) {
94
+ predecessors.set(`activity_${a.id}`, [`goal_${a.goal_id}`]);
95
+ }
96
+ }
97
+ }
98
+ // Sort a column's items by the barycenter of their predecessors' y-centres.
99
+ // Nodes with no predecessors sort last (Infinity barycenter) so they don't
100
+ // displace connected nodes.
101
+ function barycentricSort(items, yCenters) {
102
+ return [...items].sort((a, b) => {
103
+ const pA = (predecessors.get(a.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
104
+ const pB = (predecessors.get(b.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
105
+ const bcA = pA.length > 0 ? pA.reduce((s, v) => s + v, 0) / pA.length : Infinity;
106
+ const bcB = pB.length > 0 ? pB.reduce((s, v) => s + v, 0) / pB.length : Infinity;
107
+ return bcA - bcB;
108
+ });
109
+ }
64
110
  const nodeMap = new Map();
65
111
  const nodes = [];
66
112
  const columns = [];
113
+ const yCenters = new Map(); // nodeId → y + FGCA_NODE_H / 2
67
114
  for (let ci = 0; ci < cols.length; ci++) {
68
115
  const col = cols[ci];
69
116
  const x = FGCA_PAD + ci * colStride;
70
117
  columns.push({ col, x });
118
+ const items = ci === 0 ? colItems[col] : barycentricSort(colItems[col], yCenters);
71
119
  let y = FGCA_PAD + FGCA_HEADER_H + rowGap;
72
- for (const item of colItems[col]) {
120
+ for (const item of items) {
73
121
  const node = { id: item.id, x, y, label: item.label, col };
74
122
  nodes.push(node);
75
123
  nodeMap.set(item.id, node);
124
+ yCenters.set(item.id, y + FGCA_NODE_H / 2);
76
125
  y += FGCA_NODE_H + rowGap;
77
126
  }
78
127
  }
@@ -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
  };
@@ -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.3.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -71,13 +71,11 @@ describe('activity-card worked example (eu-programme)', () => {
71
71
  // Description row
72
72
  expect(layout.descriptionRow?.label).toBe('Description');
73
73
  // Chain sections
74
- expect(layout.chainSections.map((s) => s.type)).toEqual(['drivers', 'assessments', 'goals', 'changes']);
74
+ expect(layout.chainSections.map((s) => s.type)).toEqual(['drivers', 'goals', 'changes']);
75
75
  const driversSection = layout.chainSections.find((s) => s.type === 'drivers')!;
76
76
  const goalsSection = layout.chainSections.find((s) => s.type === 'goals')!;
77
- const assessmentsSection = layout.chainSections.find((s) => s.type === 'assessments')!;
78
77
  expect(driversSection.nodes.map((n) => n.id)).toContain('FACTOR-EU-MDR-1');
79
78
  expect(goalsSection.nodes.map((n) => n.id)).toContain('GOAL-EU-MARKET-1');
80
- expect(assessmentsSection.isEmpty).toBe(true); // no assessment in this fixture
81
79
  // Chain edges
82
80
  expect(layout.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'GOAL-EU-MARKET-1' });
83
81
  expect(layout.chainEdges).toContainEqual({ sourceId: 'GOAL-EU-MARKET-1', targetId: 'CHANGE-EU-COMPLIANCE-1' });
@@ -94,20 +94,17 @@ describe('layoutActivityCard', () => {
94
94
  expect(l.descriptionRow).toBeUndefined();
95
95
  });
96
96
 
97
- it('four chain sections in order: drivers, assessments, goals, changes', () => {
97
+ it('three chain sections in order: drivers, goals, changes', () => {
98
98
  const l = layoutActivityCard(RESOLVED);
99
- expect(l.chainSections.map((s) => s.type)).toEqual(['drivers', 'assessments', 'goals', 'changes']);
99
+ expect(l.chainSections.map((s) => s.type)).toEqual(['drivers', 'goals', 'changes']);
100
100
  });
101
101
 
102
102
  it('chain sections carry the resolved nodes', () => {
103
103
  const l = layoutActivityCard(RESOLVED);
104
104
  const drivers = l.chainSections.find((s) => s.type === 'drivers')!;
105
- const assessments = l.chainSections.find((s) => s.type === 'assessments')!;
106
105
  const goals = l.chainSections.find((s) => s.type === 'goals')!;
107
106
  const changes = l.chainSections.find((s) => s.type === 'changes')!;
108
107
  expect(drivers.nodes.map((n) => n.id)).toEqual(['FACTOR-EU-MDR-1']);
109
- expect(assessments.nodes.map((n) => n.id)).toEqual(['ASSESSMENT-MDR-1']);
110
- expect(assessments.nodes[0].meta).toBe('2026-03-01');
111
108
  expect(goals.nodes.map((n) => n.id)).toEqual(['GOAL-EU-MARKET-1']);
112
109
  expect(changes.nodes.map((n) => n.id)).toEqual(['CHANGE-EU-COMPLIANCE-1']);
113
110
  });
@@ -127,10 +124,6 @@ describe('layoutActivityCard', () => {
127
124
  expect(l.chainEdges).toContainEqual({ sourceId: 'GOAL-EU-MARKET-1', targetId: 'CHANGE-EU-COMPLIANCE-1' });
128
125
  });
129
126
 
130
- it('builds driver→assessment edge when assessment is linked', () => {
131
- const l = layoutActivityCard(RESOLVED);
132
- expect(l.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'ASSESSMENT-MDR-1' });
133
- });
134
127
 
135
128
  it('milestone ArchiMate class is set', () => {
136
129
  const l = layoutActivityCard(RESOLVED);
@@ -217,6 +217,33 @@ describe('resolveActivityCard', () => {
217
217
  expect(r.warnings.some((w) => w.code === 'PC-001')).toBe(true);
218
218
  });
219
219
 
220
+ it('resolves stakeholders from inline stakeholders: [] on the Action element', () => {
221
+ const projectWithInlineStakeholders = { ...PROJECT, stakeholders: ['STAKEHOLDER-OPS-1'] };
222
+ const r = resolveActivityCard(CARD, {
223
+ elements: [projectWithInlineStakeholders, CHILD, FACTOR, GOAL, CHANGE, STAKEHOLDER],
224
+ relations: [REL_GOAL],
225
+ });
226
+ expect(r.valid).toBe(true);
227
+ const sh = r.resolved!.stakeholders!;
228
+ expect(sh).toHaveLength(1);
229
+ expect(sh[0].id).toBe('STAKEHOLDER-OPS-1');
230
+ expect(sh[0].name).toBe('Operations');
231
+ expect(sh[0].role).toBeUndefined();
232
+ });
233
+
234
+ it('REL stakeholder takes precedence over inline duplicate (carries role)', () => {
235
+ const projectWithInlineStakeholders = { ...PROJECT, stakeholders: ['STAKEHOLDER-OPS-1'] };
236
+ const r = resolveActivityCard(CARD, {
237
+ elements: [projectWithInlineStakeholders, CHILD, FACTOR, GOAL, CHANGE, STAKEHOLDER],
238
+ relations: [REL_GOAL, REL_STAKEHOLDER],
239
+ });
240
+ expect(r.valid).toBe(true);
241
+ const sh = r.resolved!.stakeholders!;
242
+ // deduplicated — REL entry wins, inline duplicate is dropped
243
+ expect(sh).toHaveLength(1);
244
+ expect(sh[0].role).toBe('sponsor');
245
+ });
246
+
220
247
  it('falls back to the inline goals field when no activity_goal relation exists', () => {
221
248
  const projectWithInline = { ...PROJECT, goals: ['GOAL-EU-MARKET-1'] };
222
249
  const r = resolveActivityCard(CARD, {
@@ -183,7 +183,7 @@ export function layoutActivityCard(
183
183
  cursorY += height + SECTION_GAP;
184
184
  }
185
185
 
186
- // ── 5. Chain: Drivers → Assessments → Goals → Changes ────────────────────
186
+ // ── 5. Chain: Drivers → Goals → Changes ──────────────────────────────────
187
187
  // Each section is a full-width band with a header label and stacked node rows.
188
188
  // An empty section shows a gap indicator ("— not on file") so the author
189
189
  // sees which parts of the narrative are missing.
@@ -222,14 +222,10 @@ export function layoutActivityCard(
222
222
  }
223
223
 
224
224
  const { drivers, goals, changes } = card.motivation;
225
- const assessments = card.assessments ?? [];
226
225
 
227
226
  pushChainSection('drivers', 'Drivers', 'What prompted this initiative?',
228
227
  drivers.map((d) => ({ id: d.id, name: d.name })));
229
228
 
230
- pushChainSection('assessments', 'Assessments', 'What is the current gap?',
231
- assessments.map((a) => ({ id: a.id, name: a.name, meta: a.observed_at })));
232
-
233
229
  pushChainSection('goals', 'Goals', 'What outcome do we target?',
234
230
  goals.map((g) => ({ id: g.id, name: g.name })));
235
231
 
@@ -242,9 +238,6 @@ export function layoutActivityCard(
242
238
  // Node-level chain edges.
243
239
  const driverIdSet = new Set(drivers.map((d) => d.id));
244
240
  const goalIdSet = new Set(goals.map((g) => g.id));
245
- for (const a of assessments) {
246
- if (driverIdSet.has(a.driverId)) chainEdges.push({ sourceId: a.driverId, targetId: a.id });
247
- }
248
241
  for (const g of goals) {
249
242
  for (const did of g.driverIds) {
250
243
  if (driverIdSet.has(did)) chainEdges.push({ sourceId: did, targetId: g.id });
@@ -372,10 +372,19 @@ export function resolveActivityCard(
372
372
  // element name, falling back to the id when the element is absent.
373
373
  const goalNames = projectGoalIds.map((gid) => str(goalElems.get(gid)?.['name']) ?? gid);
374
374
 
375
- // ── Stakeholders — active `activity_stakeholder` relations from the project ──
375
+ // ── Stakeholders — inline `stakeholders: []` on the Action element PLUS active
376
+ // `action_stakeholder` / `activity_stakeholder` REL files. Both sources are
377
+ // merged; REL entries take precedence (they carry the role field).
376
378
  const stakeholderElems = collectByNotation(sources.elements, 'stakeholder');
379
+ const relStakeholderRefs = activeActivityStakeholders(sources.relations, projectId);
380
+ const relStakeholderIds = new Set(relStakeholderRefs.map((s) => s.id));
381
+ const inlineStakeholderIds = strArray(projectRec['stakeholders']).filter((id) => !relStakeholderIds.has(id));
382
+ const allStakeholderRefs: Array<{ id: string; role?: string }> = [
383
+ ...relStakeholderRefs,
384
+ ...inlineStakeholderIds.map((id) => ({ id })),
385
+ ];
377
386
  const stakeholders: ResolvedStakeholder[] = [];
378
- for (const { id: sid, role } of activeActivityStakeholders(sources.relations, projectId)) {
387
+ for (const { id: sid, role } of allStakeholderRefs) {
379
388
  const rec = stakeholderElems.get(sid);
380
389
  if (!rec) {
381
390
  warnings.push({
@@ -285,7 +285,7 @@ export interface ChainEdge {
285
285
  * so the author sees the missing data.
286
286
  */
287
287
  export interface ChainSectionLayout {
288
- type: 'drivers' | 'assessments' | 'goals' | 'changes';
288
+ type: 'drivers' | 'goals' | 'changes';
289
289
  /** Section header label. */
290
290
  label: string;
291
291
  /** Short question answered by this section, shown in grey after the label. */
@@ -159,9 +159,23 @@ describe('parseCanonicalFGCA — edge-driving fields populated', () => {
159
159
  expect(parsed.goals[0].factor).toHaveLength(1);
160
160
  // goal → change edge
161
161
  expect(parsed.changes[0].goal_id).not.toBe(0);
162
+ expect(parsed.changes[0].goal_id).not.toBe('');
162
163
  // change → activity edge
163
164
  expect(parsed.changes[0].activity_ids).toHaveLength(1);
164
165
  });
166
+
167
+ it('preserves canonical string IDs — not converted to sequential numbers', () => {
168
+ const r = parseCanonicalFGCA(VALID);
169
+ expect(r.valid).toBe(true);
170
+ const parsed = r.parsed!;
171
+ expect(parsed.factors[0].id).toBe('FACTOR-1');
172
+ expect(parsed.goals[0].id).toBe('GOAL-1');
173
+ expect(parsed.goals[0].factor![0].id).toBe('FACTOR-1');
174
+ expect(parsed.changes[0].id).toBe('CHANGE-1');
175
+ expect(parsed.changes[0].goal_id).toBe('GOAL-1');
176
+ expect(parsed.changes[0].activity_ids[0]).toBe('ACTIVITY-1');
177
+ expect(parsed.activities[0].id).toBe('ACTIVITY-1');
178
+ });
165
179
  });
166
180
 
167
181
  describe('parseCanonicalFGA', () => {
@@ -242,7 +256,7 @@ describe('parseCanonicalFGA — example file regression', () => {
242
256
  expect(r.errors).toEqual([]);
243
257
  expect(r.valid).toBe(true);
244
258
  // Every activity must carry a goal_id, or the FGA preview draws no edges.
245
- expect(r.parsed!.activities.every((a) => a.goal_id != null && a.goal_id !== 0)).toBe(true);
259
+ expect(r.parsed!.activities.every((a) => a.goal_id != null && a.goal_id !== 0 && a.goal_id !== '')).toBe(true);
246
260
  });
247
261
  }
248
262
  });
@@ -63,10 +63,6 @@ function asObjectArray(v: unknown): Array<Record<string, unknown>> | null {
63
63
  return v.map((el) => (el && typeof el === 'object' ? (el as Record<string, unknown>) : null) as Record<string, unknown>);
64
64
  }
65
65
 
66
- interface InternalIdMap {
67
- /** Canonical typed-string ID → internal numeric ID for layout consumption. */
68
- toInternal: Map<string, number>;
69
- }
70
66
 
71
67
  /**
72
68
  * Validate canonical FGCA YAML and, on success, return an internal-form
@@ -223,32 +219,20 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
223
219
  return out;
224
220
  }
225
221
 
226
- // Build internal id-maps (canonical string ID → internal sequential number).
227
- const idMap: InternalIdMap = { toInternal: new Map() };
228
- const allCanonicalIds = [...factorIds, ...goalIds, ...changeIds, ...activityIds];
229
- allCanonicalIds.forEach((id, i) => idMap.toInternal.set(id, i + 1));
230
-
231
- function intId(canonical: string): number {
232
- const n = idMap.toInternal.get(canonical);
233
- if (n === undefined) {
234
- // Shouldn't happen — we built the map from the same sets.
235
- throw new Error(`internal: canonical id "${canonical}" not mapped`);
236
- }
237
- return n;
238
- }
239
-
240
- // Layer entries (internal form).
222
+ // Layer entries (internal form). Canonical string IDs are passed through
223
+ // directly — no conversion to sequential numbers. The layout and renderer
224
+ // treat IDs as opaque keys and also display them verbatim to the user.
241
225
  const internalFactors: DriverItem[] = factors.map((el) => ({
242
- id: intId(String(el!['id'])),
226
+ id: String(el!['id']),
243
227
  name: String(el!['name'] ?? ''),
244
228
  }));
245
229
 
246
230
  const internalGoals: GoalItem[] = goals.map((el, i) => {
247
231
  const refIds = checkRefArray(el!, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`);
248
232
  return {
249
- id: intId(String(el!['id'])),
233
+ id: String(el!['id']),
250
234
  name: String(el!['name'] ?? ''),
251
- factor: refIds.map((r) => ({ id: intId(r) })),
235
+ factor: refIds.map((r) => ({ id: r })),
252
236
  };
253
237
  });
254
238
 
@@ -284,10 +268,10 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
284
268
  const canonicalChangeId = String(el!['id']);
285
269
  const linkedActivityIds = activitiesForChange.get(canonicalChangeId) ?? [];
286
270
  return {
287
- id: intId(canonicalChangeId),
271
+ id: canonicalChangeId,
288
272
  name: String(el!['name'] ?? ''),
289
- goal_id: goalRefs.length > 0 ? intId(goalRefs[0]) : 0,
290
- activity_ids: linkedActivityIds.map((aid) => intId(aid)),
273
+ goal_id: goalRefs.length > 0 ? goalRefs[0] : '',
274
+ activity_ids: linkedActivityIds,
291
275
  };
292
276
  });
293
277
 
@@ -295,9 +279,9 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
295
279
  const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`);
296
280
  const typeVal = typeof el!['type'] === 'string' ? el!['type'] : undefined;
297
281
  return {
298
- id: intId(String(el!['id'])),
282
+ id: String(el!['id']),
299
283
  name: String(el!['name'] ?? ''),
300
- goal_id: goalRefs.length > 0 ? intId(goalRefs[0]) : null,
284
+ goal_id: goalRefs.length > 0 ? goalRefs[0] : null,
301
285
  ...(typeVal !== undefined ? { type: typeVal } : {}),
302
286
  };
303
287
  });
@@ -154,19 +154,71 @@ export function layoutFGCAPreview(
154
154
  activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name })),
155
155
  };
156
156
 
157
+ // Build predecessor map: for each node, which node IDs in the previous column
158
+ // connect to it from the left. Used for barycenter crossing minimization.
159
+ const predecessors = new Map<string, string[]>();
160
+ for (const g of doc.goals) {
161
+ predecessors.set(`goal_${g.id}`, (g.factor ?? []).map(f => `driver_${f.id}`));
162
+ }
163
+ if (!hideChanges) {
164
+ for (const c of changes) {
165
+ predecessors.set(`change_${c.id}`, [`goal_${c.goal_id}`]);
166
+ }
167
+ const coveredActs = new Set(changes.flatMap(c => c.activity_ids.map(String)));
168
+ for (const a of doc.activities) {
169
+ const preds = changes
170
+ .filter(c => c.activity_ids.map(String).includes(String(a.id)))
171
+ .map(c => `change_${c.id}`);
172
+ if (a.goal_id != null && !coveredActs.has(String(a.id))) preds.push(`goal_${a.goal_id}`);
173
+ predecessors.set(`activity_${a.id}`, preds);
174
+ }
175
+ } else {
176
+ const connectedViaChange = new Set<string>();
177
+ for (const c of changes) {
178
+ for (const aid of c.activity_ids) {
179
+ predecessors.set(`activity_${aid}`, [`goal_${c.goal_id}`]);
180
+ connectedViaChange.add(String(aid));
181
+ }
182
+ }
183
+ for (const a of doc.activities) {
184
+ if (a.goal_id != null && !connectedViaChange.has(String(a.id))) {
185
+ predecessors.set(`activity_${a.id}`, [`goal_${a.goal_id}`]);
186
+ }
187
+ }
188
+ }
189
+
190
+ // Sort a column's items by the barycenter of their predecessors' y-centres.
191
+ // Nodes with no predecessors sort last (Infinity barycenter) so they don't
192
+ // displace connected nodes.
193
+ function barycentricSort(
194
+ items: Array<{ id: string; label: string }>,
195
+ yCenters: Map<string, number>,
196
+ ): Array<{ id: string; label: string }> {
197
+ return [...items].sort((a, b) => {
198
+ const pA = (predecessors.get(a.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
199
+ const pB = (predecessors.get(b.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
200
+ const bcA = pA.length > 0 ? pA.reduce((s, v) => s + v, 0) / pA.length : Infinity;
201
+ const bcB = pB.length > 0 ? pB.reduce((s, v) => s + v, 0) / pB.length : Infinity;
202
+ return bcA - bcB;
203
+ });
204
+ }
205
+
157
206
  const nodeMap = new Map<string, FGCAPreviewNode>();
158
207
  const nodes: FGCAPreviewNode[] = [];
159
208
  const columns: FGCAPreviewColumnPos[] = [];
209
+ const yCenters = new Map<string, number>(); // nodeId → y + FGCA_NODE_H / 2
160
210
 
161
211
  for (let ci = 0; ci < cols.length; ci++) {
162
212
  const col = cols[ci];
163
213
  const x = FGCA_PAD + ci * colStride;
164
214
  columns.push({ col, x });
215
+ const items = ci === 0 ? colItems[col] : barycentricSort(colItems[col], yCenters);
165
216
  let y = FGCA_PAD + FGCA_HEADER_H + rowGap;
166
- for (const item of colItems[col]) {
217
+ for (const item of items) {
167
218
  const node: FGCAPreviewNode = { id: item.id, x, y, label: item.label, col };
168
219
  nodes.push(node);
169
220
  nodeMap.set(item.id, node);
221
+ yCenters.set(item.id, y + FGCA_NODE_H / 2);
170
222
  y += FGCA_NODE_H + rowGap;
171
223
  }
172
224
  }
@@ -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
@@ -135,10 +135,9 @@ export function renderActivityCardBody(layout: ActivityCardLayout, ox: number, o
135
135
  });
136
136
  }
137
137
 
138
- // Chain sections (Drivers → Assessments → Goals → Changes).
138
+ // Chain sections (Drivers → Goals → Changes).
139
139
  const SECTION_LEVEL: Record<string, number> = {
140
140
  drivers: 4,
141
- assessments: 5,
142
141
  goals: 5,
143
142
  changes: 6,
144
143
  };
@@ -90,7 +90,7 @@ export function renderFgcaBody(
90
90
  `<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="8"/>`,
91
91
  `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>`,
92
92
  twoLines
93
- ? `<text class="text-secondary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>`
93
+ ? `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>`
94
94
  : '',
95
95
  `<text class="text-id" x="${n.x + NODE_W / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(entityId)}</text>`,
96
96
  ]
@@ -17,12 +17,11 @@ import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
17
17
  import { escXml } from './render-util.js';
18
18
 
19
19
  const NODE_W = 250;
20
- const NODE_H = 60;
20
+ const NODE_H = 72;
21
21
  const RANK_SEP = 100;
22
22
  const NODE_SEP = 24;
23
23
  const PAD = 24;
24
- const LABEL_MAX = 36;
25
- const LABEL_TRIM = 34;
24
+ const LABEL_CHARS = 30;
26
25
 
27
26
  export interface RenderGoalsOptions {
28
27
  treeName?: string;
@@ -110,10 +109,32 @@ export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoal
110
109
  const y = n.y + oy;
111
110
  const level = n.data.level % 8;
112
111
  const labelText = n.data.name ?? String(n.id);
113
- const label = labelText.length > LABEL_MAX ? labelText.slice(0, LABEL_TRIM) + '…' : labelText;
112
+ const words = labelText.split(' ');
113
+ let line1 = '';
114
+ let line2 = '';
115
+ for (const w of words) {
116
+ if ((line1 + ' ' + w).trim().length <= LABEL_CHARS) {
117
+ line1 = (line1 + ' ' + w).trim();
118
+ } else if ((line2 + ' ' + w).trim().length <= LABEL_CHARS) {
119
+ line2 = (line2 + ' ' + w).trim();
120
+ } else if (!line2) {
121
+ line2 = w.slice(0, LABEL_CHARS - 2) + '…';
122
+ break;
123
+ }
124
+ }
125
+ const twoLines = line2.length > 0;
126
+ const nameY1 = twoLines ? y + 12 : y + 16;
127
+ const nameY2 = y + 26;
128
+ const typeY = twoLines ? y + 43 : y + 35;
129
+ const idY = twoLines ? y + 59 : y + 55;
130
+ const typeLabel = n.data.type ?? '';
131
+ const idText = String(n.data.id);
114
132
  return `<g>
115
133
  <rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="8"/>
116
- <text class="text-primary" x="${x + n.width / 2}" y="${y + n.height / 2}" text-anchor="middle" dominant-baseline="central">${escXml(label)}</text>
134
+ <text class="text-primary" x="${x + n.width / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>${twoLines ? `
135
+ <text class="text-primary" x="${x + n.width / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : ''}${typeLabel ? `
136
+ <text class="text-secondary" x="${x + n.width / 2}" y="${typeY}" text-anchor="middle" dominant-baseline="central">${escXml(typeLabel)}</text>` : ''}
137
+ <text class="text-id" x="${x + n.width / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>
117
138
  </g>`;
118
139
  })
119
140
  .join('\n');