@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.
- package/dist/activity-card/__tests__/example.test.js +1 -3
- package/dist/activity-card/__tests__/layout.test.js +2 -9
- package/dist/activity-card/__tests__/resolver.test.js +25 -0
- package/dist/activity-card/layout.js +1 -7
- package/dist/activity-card/resolver.js +11 -2
- package/dist/activity-card/types.d.ts +1 -1
- package/dist/fgca/__tests__/parse-canonical.test.js +14 -1
- package/dist/fgca/parse-canonical.js +11 -21
- package/dist/fgca/preview-layout.js +50 -1
- package/dist/webview/render-activity-card.js +2 -3
- package/dist/webview/render-fgca.js +1 -1
- package/dist/webview/render-goals.js +28 -5
- package/package.json +1 -1
- package/src/activity-card/__tests__/example.test.ts +1 -3
- package/src/activity-card/__tests__/layout.test.ts +2 -9
- package/src/activity-card/__tests__/resolver.test.ts +27 -0
- package/src/activity-card/layout.ts +1 -8
- package/src/activity-card/resolver.ts +11 -2
- package/src/activity-card/types.ts +1 -1
- package/src/fgca/__tests__/parse-canonical.test.ts +15 -1
- package/src/fgca/parse-canonical.ts +11 -27
- package/src/fgca/preview-layout.ts +53 -1
- package/src/webview/render-activity-card.ts +2 -3
- package/src/webview/render-fgca.ts +1 -1
- package/src/webview/render-goals.ts +26 -5
|
@@ -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', '
|
|
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('
|
|
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', '
|
|
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 →
|
|
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 —
|
|
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
|
|
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' | '
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
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:
|
|
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:
|
|
192
|
+
id: String(el['id']),
|
|
203
193
|
name: String(el['name'] ?? ''),
|
|
204
|
-
factor: refIds.map((r) => ({ id:
|
|
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:
|
|
227
|
+
id: canonicalChangeId,
|
|
238
228
|
name: String(el['name'] ?? ''),
|
|
239
|
-
goal_id: goalRefs.length > 0 ?
|
|
240
|
-
activity_ids: linkedActivityIds
|
|
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:
|
|
237
|
+
id: String(el['id']),
|
|
248
238
|
name: String(el['name'] ?? ''),
|
|
249
|
-
goal_id: goalRefs.length > 0 ?
|
|
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
|
|
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 →
|
|
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 →
|
|
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-
|
|
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 =
|
|
18
|
+
const NODE_H = 72;
|
|
19
19
|
const RANK_SEP = 100;
|
|
20
20
|
const NODE_SEP = 24;
|
|
21
21
|
const PAD = 24;
|
|
22
|
-
const
|
|
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
|
|
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="${
|
|
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
|
@@ -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', '
|
|
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('
|
|
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', '
|
|
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 →
|
|
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 —
|
|
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
|
|
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' | '
|
|
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
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
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:
|
|
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:
|
|
233
|
+
id: String(el!['id']),
|
|
250
234
|
name: String(el!['name'] ?? ''),
|
|
251
|
-
factor: refIds.map((r) => ({ id:
|
|
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:
|
|
271
|
+
id: canonicalChangeId,
|
|
288
272
|
name: String(el!['name'] ?? ''),
|
|
289
|
-
goal_id: goalRefs.length > 0 ?
|
|
290
|
-
activity_ids: linkedActivityIds
|
|
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:
|
|
282
|
+
id: String(el!['id']),
|
|
299
283
|
name: String(el!['name'] ?? ''),
|
|
300
|
-
goal_id: goalRefs.length > 0 ?
|
|
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
|
|
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 →
|
|
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 →
|
|
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-
|
|
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 =
|
|
20
|
+
const NODE_H = 72;
|
|
21
21
|
const RANK_SEP = 100;
|
|
22
22
|
const NODE_SEP = 24;
|
|
23
23
|
const PAD = 24;
|
|
24
|
-
const
|
|
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
|
|
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="${
|
|
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');
|