@transitrix/diagrams 1.11.0 → 1.12.1
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/activities/validate.js +8 -4
- package/dist/capability-map/CapabilityCardNode.d.ts +1 -0
- package/dist/capability-map/CapabilityCardNode.js +5 -4
- package/dist/capability-map/CapabilityMapView.js +1 -0
- package/dist/capability-map/dsm-layout.js +5 -4
- package/dist/capability-map/dsm-schema.d.ts +1 -0
- package/dist/fgca/parse-canonical.d.ts +14 -2
- package/dist/fgca/parse-canonical.js +31 -8
- package/dist/fgca/resolver.js +16 -0
- package/dist/goals/GoalNode.d.ts +1 -0
- package/dist/goals/GoalNode.js +5 -4
- package/dist/goals/GoalTreeView.js +1 -0
- package/dist/goals/layout.js +7 -5
- package/dist/goals/types.d.ts +2 -0
- package/dist/webview/entry.js +26 -4
- package/dist/webview/render-fgca.d.ts +2 -0
- package/dist/webview/render-fgca.js +8 -2
- package/package.json +3 -3
- package/src/activities/validate.ts +8 -4
- package/src/capability-map/CapabilityCardNode.tsx +8 -6
- package/src/capability-map/CapabilityMapView.tsx +1 -0
- package/src/capability-map/dsm-layout.ts +5 -4
- package/src/capability-map/dsm-schema.ts +1 -0
- package/src/fgca/parse-canonical.ts +44 -7
- package/src/fgca/resolver.ts +24 -0
- package/src/goals/GoalNode.tsx +8 -6
- package/src/goals/GoalTreeView.tsx +1 -0
- package/src/goals/layout.ts +7 -5
- package/src/goals/types.ts +2 -0
- package/src/webview/entry.ts +26 -4
- package/src/webview/render-fgca.ts +12 -1
|
@@ -199,8 +199,9 @@ export function validateActivities(input) {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
// ACT-011: warn if no duration (duration_days is accepted as an alias)
|
|
202
|
+
// Network view is unaffected; this is only about CPM/Gantt rendering.
|
|
202
203
|
if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
|
|
203
|
-
warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration —
|
|
204
|
+
warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — Gantt view and CPM analysis will exclude it. Network view is unaffected.`, path });
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
207
|
if (errors.length > 0)
|
|
@@ -269,7 +270,9 @@ export function validateActivities(input) {
|
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
272
|
}
|
|
272
|
-
// ACT-013: orphan activities (warning only)
|
|
273
|
+
// ACT-013: orphan activities (warning only). An activity is structurally orphan
|
|
274
|
+
// if it has no connections to the rest of the graph. Valid connections are:
|
|
275
|
+
// predecessors, successors, parent, or goals (direct or via delivers_changes).
|
|
273
276
|
const successorIds = new Set();
|
|
274
277
|
for (const a of doc.activities) {
|
|
275
278
|
for (const pred of (a.predecessors ?? []))
|
|
@@ -280,10 +283,11 @@ export function validateActivities(input) {
|
|
|
280
283
|
const hasSuccessors = successorIds.has(a.id);
|
|
281
284
|
const hasGoals = Array.isArray(a.goals) && a.goals.length > 0;
|
|
282
285
|
const hasPredecessors = Array.isArray(a.predecessors) && a.predecessors.length > 0;
|
|
283
|
-
|
|
286
|
+
const hasParent = typeof a.parent === 'string' && a.parent.length > 0;
|
|
287
|
+
if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors && !hasParent) {
|
|
284
288
|
warnings.push({
|
|
285
289
|
code: 'ACT-013',
|
|
286
|
-
message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, or goals)`,
|
|
290
|
+
message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, parent, or goals)`,
|
|
287
291
|
path: `activities[${i}]`,
|
|
288
292
|
});
|
|
289
293
|
}
|
|
@@ -9,7 +9,7 @@ function latestMaturityLevel(cap) {
|
|
|
9
9
|
return [...cap.maturity].sort((a, b) => b.date.localeCompare(a.date))[0].level;
|
|
10
10
|
}
|
|
11
11
|
const CapabilityCardNode = memo(({ data }) => {
|
|
12
|
-
const { capability, theme, maturityColours, readOnly, isDropTarget, hasHiddenChildren, isCollapsed } = data;
|
|
12
|
+
const { capability, theme, maturityColours, readOnly, isDropTarget, hasChildren, hasHiddenChildren, isCollapsed } = data;
|
|
13
13
|
const maturity = latestMaturityLevel(capability);
|
|
14
14
|
const dotColour = maturity !== undefined ? maturityColours[maturity] : undefined;
|
|
15
15
|
return (_jsxs("div", { style: {
|
|
@@ -53,14 +53,15 @@ const CapabilityCardNode = memo(({ data }) => {
|
|
|
53
53
|
position: 'absolute', bottom: -8, right: -8, width: 16, height: 16,
|
|
54
54
|
borderRadius: '50%', border: '1px solid #fca5a5', background: '#ffffff',
|
|
55
55
|
color: '#dc2626', fontSize: 11, lineHeight: '14px', padding: 0, cursor: 'pointer',
|
|
56
|
-
}, children: "\u00D7" })), hasHiddenChildren && (_jsx("button", { type: "button", "aria-label": isCollapsed ? 'Expand branch' : 'Collapse branch', title: isCollapsed ? 'Expand branch' : 'Collapse branch', onClick: (e) => {
|
|
56
|
+
}, children: "\u00D7" })), (hasChildren || hasHiddenChildren) && (_jsx("button", { type: "button", "aria-label": isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch', title: isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch', onClick: (e) => {
|
|
57
57
|
e.stopPropagation();
|
|
58
58
|
data.onToggleCollapse(capability.id);
|
|
59
59
|
}, style: {
|
|
60
60
|
position: 'absolute', top: '50%', right: -8, transform: 'translateY(-50%)',
|
|
61
61
|
width: 16, height: 16, borderRadius: '50%', border: `1px solid ${theme.cardBorderColor}`,
|
|
62
|
-
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
63
|
-
|
|
62
|
+
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
63
|
+
cursor: 'pointer', zIndex: 10,
|
|
64
|
+
}, children: isCollapsed || hasHiddenChildren ? '+' : '−' }))] }));
|
|
64
65
|
});
|
|
65
66
|
CapabilityCardNode.displayName = 'CapabilityCardNode';
|
|
66
67
|
export default CapabilityCardNode;
|
|
@@ -101,6 +101,7 @@ function CapabilityMapViewInner({ map, layout: layoutProp, layoutOptions, theme:
|
|
|
101
101
|
maturityColours,
|
|
102
102
|
readOnly,
|
|
103
103
|
isDropTarget: hoveredId === n.id,
|
|
104
|
+
hasChildren: n.hasChildren,
|
|
104
105
|
hasHiddenChildren: n.hasHiddenChildren,
|
|
105
106
|
isCollapsed: collapsedIds.has(n.id),
|
|
106
107
|
onAddChild,
|
|
@@ -68,11 +68,12 @@ export function layoutCapabilityMap(map, options = {}) {
|
|
|
68
68
|
const cap = byId.get(id);
|
|
69
69
|
const allChildIds = children.get(id) ?? [];
|
|
70
70
|
const childIds = allChildIds.filter((c) => !hiddenSubtrees.has(c));
|
|
71
|
+
const hasChildren = allChildIds.length > 0;
|
|
71
72
|
const hasHiddenChildren = childIds.length < allChildIds.length;
|
|
72
73
|
if (childIds.length === 0) {
|
|
73
74
|
const y = getNextY(col);
|
|
74
75
|
advanceY(col, nodeHeight + nodeSep);
|
|
75
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
|
|
76
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasChildren, hasHiddenChildren });
|
|
76
77
|
return { top: y, bottom: y + nodeHeight };
|
|
77
78
|
}
|
|
78
79
|
const spans = childIds.map((cid) => placeSubtree(cid, col + 1));
|
|
@@ -82,7 +83,7 @@ export function layoutCapabilityMap(map, options = {}) {
|
|
|
82
83
|
const curColY = getNextY(col);
|
|
83
84
|
const finalY = Math.max(parentY, curColY);
|
|
84
85
|
advanceY(col, finalY - curColY + nodeHeight + nodeSep);
|
|
85
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
|
|
86
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasChildren, hasHiddenChildren });
|
|
86
87
|
for (const cid of childIds)
|
|
87
88
|
edges.push({ source: id, target: cid });
|
|
88
89
|
return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + nodeHeight, spanBottom) };
|
|
@@ -95,7 +96,7 @@ export function layoutCapabilityMap(map, options = {}) {
|
|
|
95
96
|
columnNextY.set(col, y + nodeSep);
|
|
96
97
|
}
|
|
97
98
|
if (nodes.length === 0) {
|
|
98
|
-
const rootNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
|
|
99
|
+
const rootNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasChildren: false, hasHiddenChildren: false };
|
|
99
100
|
return { rootNode, nodes: [], edges: [], bounds: { x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight } };
|
|
100
101
|
}
|
|
101
102
|
const minX = Math.min(...nodes.map((n) => n.x));
|
|
@@ -107,7 +108,7 @@ export function layoutCapabilityMap(map, options = {}) {
|
|
|
107
108
|
const rootTop = Math.min(...l1Nodes.map((n) => n.y));
|
|
108
109
|
const rootBottom = Math.max(...l1Nodes.map((n) => n.y + n.height));
|
|
109
110
|
const rootY = rootTop + (rootBottom - rootTop) / 2 - nodeHeight / 2;
|
|
110
|
-
const rootNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
|
|
111
|
+
const rootNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasChildren: l1Ids.length > 0, hasHiddenChildren: false };
|
|
111
112
|
const rootEdges = l1Ids
|
|
112
113
|
.filter((id) => !hiddenSubtrees.has(id))
|
|
113
114
|
.map((id) => ({ source: 'root', target: id }));
|
|
@@ -28,17 +28,29 @@ import type { ValidationResult } from '../validation-types.js';
|
|
|
28
28
|
export interface CanonicalFGCAResult extends ValidationResult {
|
|
29
29
|
/** On success, the internal-form FGCADoc derived from the canonical input. */
|
|
30
30
|
parsed?: FGCADoc;
|
|
31
|
+
/** True when a goal-scoped projection should show the scope caption. */
|
|
32
|
+
scopeCaption?: boolean;
|
|
31
33
|
}
|
|
32
34
|
/** DGA mode (`view_config.layers.changes: off`) omits the Changes column. */
|
|
33
35
|
export declare function isDgcaChangesLayerOff(input: unknown): boolean;
|
|
34
36
|
/**
|
|
35
37
|
* Validate canonical FGCA YAML and, on success, return an internal-form
|
|
36
38
|
* `FGCADoc` ready for `buildFGCALayout`.
|
|
39
|
+
*
|
|
40
|
+
* @param input The FGCA document to parse
|
|
41
|
+
* @param outOfScopeGoalIds When a goal-scoped projection omits goals that exist
|
|
42
|
+
* in the catalogue, pass their IDs here to suppress FGCA-011/FGCA-009 errors
|
|
43
|
+
* for references to out-of-scope goals. When provided and non-empty, and at least
|
|
44
|
+
* one action or change references an out-of-scope goal, `scopeCaption` is set true.
|
|
45
|
+
* @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
|
|
46
|
+
* in the catalogue, pass their IDs here to suppress FGCA-008 errors.
|
|
37
47
|
*/
|
|
38
|
-
export declare function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult;
|
|
48
|
+
export declare function parseCanonicalFGCA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null): CanonicalFGCAResult;
|
|
39
49
|
/** On success, the internal-form FGCADoc derived from canonical FGA input. */
|
|
40
50
|
export interface CanonicalFGAResult extends ValidationResult {
|
|
41
51
|
parsed?: FGCADoc;
|
|
52
|
+
/** True when a goal-scoped projection should show the scope caption. */
|
|
53
|
+
scopeCaption?: boolean;
|
|
42
54
|
}
|
|
43
55
|
/**
|
|
44
56
|
* Canonical-form FGA parser + validator.
|
|
@@ -57,4 +69,4 @@ export interface CanonicalFGAResult extends ValidationResult {
|
|
|
57
69
|
* the canonical FGA path is unit-testable and the library owns the single
|
|
58
70
|
* canon shape — flat form only, no `fga:` wrapper.
|
|
59
71
|
*/
|
|
60
|
-
export declare function parseCanonicalFGA(input: unknown): CanonicalFGAResult;
|
|
72
|
+
export declare function parseCanonicalFGA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null): CanonicalFGAResult;
|
|
@@ -59,8 +59,16 @@ export function isDgcaChangesLayerOff(input) {
|
|
|
59
59
|
/**
|
|
60
60
|
* Validate canonical FGCA YAML and, on success, return an internal-form
|
|
61
61
|
* `FGCADoc` ready for `buildFGCALayout`.
|
|
62
|
+
*
|
|
63
|
+
* @param input The FGCA document to parse
|
|
64
|
+
* @param outOfScopeGoalIds When a goal-scoped projection omits goals that exist
|
|
65
|
+
* in the catalogue, pass their IDs here to suppress FGCA-011/FGCA-009 errors
|
|
66
|
+
* for references to out-of-scope goals. When provided and non-empty, and at least
|
|
67
|
+
* one action or change references an out-of-scope goal, `scopeCaption` is set true.
|
|
68
|
+
* @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
|
|
69
|
+
* in the catalogue, pass their IDs here to suppress FGCA-008 errors.
|
|
62
70
|
*/
|
|
63
|
-
export function parseCanonicalFGCA(input) {
|
|
71
|
+
export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds) {
|
|
64
72
|
const errors = [];
|
|
65
73
|
const warnings = [];
|
|
66
74
|
if (!input || typeof input !== 'object') {
|
|
@@ -173,8 +181,10 @@ export function parseCanonicalFGCA(input) {
|
|
|
173
181
|
activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTION_OR_ACTIVITY_ID_RE, activityIds));
|
|
174
182
|
if (errors.length > 0)
|
|
175
183
|
return { valid: false, errors, warnings };
|
|
184
|
+
// Track if any out-of-scope goals/factors are referenced (for scope caption).
|
|
185
|
+
let hasOutOfScopeRef = false;
|
|
176
186
|
// Cross-refs.
|
|
177
|
-
function checkRefArray(el, field, targets, refRe, code, path) {
|
|
187
|
+
function checkRefArray(el, field, targets, refRe, code, path, outOfScope) {
|
|
178
188
|
const ref = el[field];
|
|
179
189
|
if (ref === undefined || ref === null)
|
|
180
190
|
return [];
|
|
@@ -192,6 +202,12 @@ export function parseCanonicalFGCA(input) {
|
|
|
192
202
|
errors.push({ code: 'FGCA-007', message: `${path}.${field}[] entry "${r}" does not match the expected grammar`, path });
|
|
193
203
|
continue;
|
|
194
204
|
}
|
|
205
|
+
if (outOfScope?.has(r)) {
|
|
206
|
+
// Reference is out-of-scope: suppress error and mark that we found an out-of-scope reference (for caption).
|
|
207
|
+
hasOutOfScopeRef = true;
|
|
208
|
+
out.push(r);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
195
211
|
if (!targets.has(r)) {
|
|
196
212
|
errors.push({ code, message: `${path}.${field}[] references undeclared element "${r}"`, path });
|
|
197
213
|
continue;
|
|
@@ -208,7 +224,7 @@ export function parseCanonicalFGCA(input) {
|
|
|
208
224
|
name: String(el['name'] ?? ''),
|
|
209
225
|
}));
|
|
210
226
|
const internalGoals = goals.map((el, i) => {
|
|
211
|
-
const refIds = checkRefArray(el, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]
|
|
227
|
+
const refIds = checkRefArray(el, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`, outOfScopeFactorIds);
|
|
212
228
|
return {
|
|
213
229
|
id: String(el['id']),
|
|
214
230
|
name: String(el['name'] ?? ''),
|
|
@@ -233,7 +249,7 @@ export function parseCanonicalFGCA(input) {
|
|
|
233
249
|
}
|
|
234
250
|
}
|
|
235
251
|
const internalChanges = changes.map((el, i) => {
|
|
236
|
-
const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-009', `changes[${i}]
|
|
252
|
+
const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-009', `changes[${i}]`, outOfScopeGoalIds);
|
|
237
253
|
// Internal `change.goal_id` is singular; canonical `change.goals` is plural.
|
|
238
254
|
// First goal wins for the singular field; warn (not error) if multiple.
|
|
239
255
|
if (goalRefs.length > 1) {
|
|
@@ -253,7 +269,7 @@ export function parseCanonicalFGCA(input) {
|
|
|
253
269
|
};
|
|
254
270
|
});
|
|
255
271
|
const internalActivities = activities.map((el, i) => {
|
|
256
|
-
const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]
|
|
272
|
+
const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`, outOfScopeGoalIds);
|
|
257
273
|
const typeVal = typeof el['type'] === 'string' ? el['type'] : undefined;
|
|
258
274
|
return {
|
|
259
275
|
id: String(el['id']),
|
|
@@ -292,7 +308,13 @@ export function parseCanonicalFGCA(input) {
|
|
|
292
308
|
activities: internalActivities,
|
|
293
309
|
hideChanges: isDgcaChangesLayerOff(raw),
|
|
294
310
|
};
|
|
295
|
-
return {
|
|
311
|
+
return {
|
|
312
|
+
valid: true,
|
|
313
|
+
errors,
|
|
314
|
+
warnings,
|
|
315
|
+
parsed,
|
|
316
|
+
scopeCaption: (outOfScopeGoalIds?.size ?? 0) > 0 && hasOutOfScopeRef,
|
|
317
|
+
};
|
|
296
318
|
}
|
|
297
319
|
const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
298
320
|
/**
|
|
@@ -312,7 +334,7 @@ const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
|
312
334
|
* the canonical FGA path is unit-testable and the library owns the single
|
|
313
335
|
* canon shape — flat form only, no `fga:` wrapper.
|
|
314
336
|
*/
|
|
315
|
-
export function parseCanonicalFGA(input) {
|
|
337
|
+
export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds) {
|
|
316
338
|
if (!input || typeof input !== 'object') {
|
|
317
339
|
return { valid: false, errors: [{ code: 'FGA-001', message: 'document root is not an object' }], warnings: [] };
|
|
318
340
|
}
|
|
@@ -339,7 +361,7 @@ export function parseCanonicalFGA(input) {
|
|
|
339
361
|
// codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
|
|
340
362
|
// are unreachable here because `changes` is empty.
|
|
341
363
|
const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [] };
|
|
342
|
-
const r = parseCanonicalFGCA(synth);
|
|
364
|
+
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
343
365
|
const remap = {
|
|
344
366
|
'FGCA-001': 'FGA-001',
|
|
345
367
|
'FGCA-002': 'FGA-002',
|
|
@@ -359,5 +381,6 @@ export function parseCanonicalFGA(input) {
|
|
|
359
381
|
errors: r.errors.map((e) => ({ ...e, code: remapCode(e.code) })),
|
|
360
382
|
warnings: r.warnings.map((w) => ({ ...w, code: remapCode(w.code) })),
|
|
361
383
|
parsed: r.parsed,
|
|
384
|
+
scopeCaption: r.scopeCaption,
|
|
362
385
|
};
|
|
363
386
|
}
|
package/dist/fgca/resolver.js
CHANGED
|
@@ -122,6 +122,20 @@ export function resolveFGCA(viewDoc, sources) {
|
|
|
122
122
|
selectedActivities = [...activityElems.values()].filter((a) => strArray(a[actionChangeLinkField(a)]).some((cid) => selectedChangeIds.has(cid)) ||
|
|
123
123
|
strArray(a['goals']).some((gid) => selectedGoalIds.has(gid)));
|
|
124
124
|
}
|
|
125
|
+
// Collect out-of-scope goals (exist in catalogue but not in selected set).
|
|
126
|
+
// Used by the validator to suppress errors for references to goals that exist
|
|
127
|
+
// but are outside the scope.
|
|
128
|
+
let outOfScopeGoalIds;
|
|
129
|
+
if (goalsFilter !== 'all') {
|
|
130
|
+
// Goal scope is restricted; collect goals that exist but aren't selected.
|
|
131
|
+
outOfScopeGoalIds = new Set([...goalElems.keys()].filter((id) => !selectedGoalIds.has(id)));
|
|
132
|
+
}
|
|
133
|
+
// Collect out-of-scope factors (exist in catalogue but not in selected set).
|
|
134
|
+
const selectedFactorIds = new Set(selectedFactors.map((f) => str(f['id'])).filter((x) => x !== undefined));
|
|
135
|
+
let outOfScopeFactorIds;
|
|
136
|
+
if (goalsFilter !== 'all') {
|
|
137
|
+
outOfScopeFactorIds = new Set([...factorElems.keys()].filter((id) => !selectedFactorIds.has(id)));
|
|
138
|
+
}
|
|
125
139
|
return {
|
|
126
140
|
notation: 'dgca',
|
|
127
141
|
id: viewDoc['id'],
|
|
@@ -132,5 +146,7 @@ export function resolveFGCA(viewDoc, sources) {
|
|
|
132
146
|
changes: selectedChanges,
|
|
133
147
|
actions: selectedActivities,
|
|
134
148
|
view_config: viewDoc['view_config'],
|
|
149
|
+
__outOfScopeGoalIds: outOfScopeGoalIds,
|
|
150
|
+
__outOfScopeFactorIds: outOfScopeFactorIds,
|
|
135
151
|
};
|
|
136
152
|
}
|
package/dist/goals/GoalNode.d.ts
CHANGED
package/dist/goals/GoalNode.js
CHANGED
|
@@ -13,7 +13,7 @@ function impactSign(f) {
|
|
|
13
13
|
return 'negative';
|
|
14
14
|
}
|
|
15
15
|
const GoalNode = memo(({ data }) => {
|
|
16
|
-
const { goal, theme, readOnly, isDropTarget, hasHiddenChildren, isCollapsed } = data;
|
|
16
|
+
const { goal, theme, readOnly, isDropTarget, hasChildren, hasHiddenChildren, isCollapsed } = data;
|
|
17
17
|
const [openPanel, setOpenPanel] = useState(null);
|
|
18
18
|
const positive = (goal.factors ?? []).filter((f) => impactSign(f) !== 'negative');
|
|
19
19
|
const negative = (goal.factors ?? []).filter((f) => impactSign(f) !== 'positive');
|
|
@@ -83,14 +83,15 @@ const GoalNode = memo(({ data }) => {
|
|
|
83
83
|
position: 'absolute', bottom: -8, right: -8, width: 16, height: 16,
|
|
84
84
|
borderRadius: '50%', border: '1px solid #fca5a5', background: '#ffffff',
|
|
85
85
|
color: '#dc2626', fontSize: 11, lineHeight: '14px', padding: 0, cursor: 'pointer',
|
|
86
|
-
}, children: "\u00D7" })), hasHiddenChildren && (_jsx("button", { type: "button", "aria-label": isCollapsed ? 'Expand branch' : 'Collapse branch', title: isCollapsed ? 'Expand branch' : 'Collapse branch', onClick: (e) => {
|
|
86
|
+
}, children: "\u00D7" })), (hasChildren || hasHiddenChildren) && (_jsx("button", { type: "button", "aria-label": isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch', title: isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch', onClick: (e) => {
|
|
87
87
|
e.stopPropagation();
|
|
88
88
|
data.onToggleCollapse(goal.id);
|
|
89
89
|
}, style: {
|
|
90
90
|
position: 'absolute', top: '50%', right: -8, transform: 'translateY(-50%)',
|
|
91
91
|
width: 16, height: 16, borderRadius: '50%', border: `1px solid ${theme.cardBorderColor}`,
|
|
92
|
-
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
93
|
-
|
|
92
|
+
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
93
|
+
cursor: 'pointer', zIndex: 10,
|
|
94
|
+
}, children: isCollapsed || hasHiddenChildren ? '+' : '−' }))] }));
|
|
94
95
|
});
|
|
95
96
|
GoalNode.displayName = 'GoalNode';
|
|
96
97
|
export default GoalNode;
|
|
@@ -122,6 +122,7 @@ function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: the
|
|
|
122
122
|
theme,
|
|
123
123
|
readOnly,
|
|
124
124
|
isDropTarget: hoveredId === n.id,
|
|
125
|
+
hasChildren: n.hasChildren,
|
|
125
126
|
hasHiddenChildren: n.hasHiddenChildren,
|
|
126
127
|
isCollapsed: collapsedIds.has(n.id),
|
|
127
128
|
onAddChild,
|
package/dist/goals/layout.js
CHANGED
|
@@ -100,14 +100,16 @@ export function layoutGoalTree(tree, options = {}) {
|
|
|
100
100
|
return { top: 0, bottom: 0 };
|
|
101
101
|
placed.add(id);
|
|
102
102
|
const col = levelToCol.get(goal.level) ?? 0;
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
103
|
+
const allChildIds = children.get(id) ?? [];
|
|
104
|
+
const childIds = allChildIds.filter(c => !hiddenSubtrees.has(c) && !placed.has(c));
|
|
105
|
+
const isCollapsedRoot = hiddenSet.has(id) && allChildIds.length > 0;
|
|
106
|
+
const hasChildren = allChildIds.length > 0;
|
|
107
|
+
const hasHiddenChildren = childIds.length < allChildIds.length;
|
|
106
108
|
if (childIds.length === 0) {
|
|
107
109
|
// Leaf: place at current column Y
|
|
108
110
|
const y = getNextY(col);
|
|
109
111
|
advanceY(col, nodeHeight + nodeSep);
|
|
110
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasHiddenChildren });
|
|
112
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasChildren, hasHiddenChildren });
|
|
111
113
|
return { top: y, bottom: y + nodeHeight };
|
|
112
114
|
}
|
|
113
115
|
// Place children first to get their span
|
|
@@ -122,7 +124,7 @@ export function layoutGoalTree(tree, options = {}) {
|
|
|
122
124
|
const curColY = getNextY(col);
|
|
123
125
|
const finalY = Math.max(parentY, curColY);
|
|
124
126
|
advanceY(col, finalY - curColY + nodeHeight + nodeSep);
|
|
125
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasHiddenChildren });
|
|
127
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasChildren, hasHiddenChildren });
|
|
126
128
|
for (const cid of childIds) {
|
|
127
129
|
edges.push({ source: id, target: cid });
|
|
128
130
|
}
|
package/dist/goals/types.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface LaidOutNode {
|
|
|
55
55
|
height: number;
|
|
56
56
|
data: Goal;
|
|
57
57
|
isCollapsedRoot: boolean;
|
|
58
|
+
/** True when this node has at least one child in the source tree (visible or hidden). */
|
|
59
|
+
hasChildren: boolean;
|
|
58
60
|
hasHiddenChildren: boolean;
|
|
59
61
|
}
|
|
60
62
|
export interface LaidOutEdge {
|
package/dist/webview/entry.js
CHANGED
|
@@ -113,22 +113,44 @@ function dispatchValidate(kind, doc) {
|
|
|
113
113
|
return r;
|
|
114
114
|
}
|
|
115
115
|
case 'dgca': {
|
|
116
|
-
const
|
|
116
|
+
const meta = (doc ?? {});
|
|
117
|
+
const outOfScopeGoalIds = meta['__outOfScopeGoalIds'] instanceof Set
|
|
118
|
+
? meta['__outOfScopeGoalIds']
|
|
119
|
+
: Array.isArray(meta['__outOfScopeGoalIds'])
|
|
120
|
+
? new Set(meta['__outOfScopeGoalIds'])
|
|
121
|
+
: undefined;
|
|
122
|
+
const outOfScopeFactorIds = meta['__outOfScopeFactorIds'] instanceof Set
|
|
123
|
+
? meta['__outOfScopeFactorIds']
|
|
124
|
+
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
125
|
+
? new Set(meta['__outOfScopeFactorIds'])
|
|
126
|
+
: undefined;
|
|
127
|
+
const v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
117
128
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
118
129
|
r.errors.push(...v.errors);
|
|
119
130
|
r.warnings.push(...v.warnings);
|
|
120
131
|
if (v.valid && v.parsed) {
|
|
121
|
-
r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca' });
|
|
132
|
+
r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca', scopeCaption: v.scopeCaption });
|
|
122
133
|
}
|
|
123
134
|
return r;
|
|
124
135
|
}
|
|
125
136
|
case 'dga': {
|
|
126
|
-
const
|
|
137
|
+
const meta = (doc ?? {});
|
|
138
|
+
const outOfScopeGoalIds = meta['__outOfScopeGoalIds'] instanceof Set
|
|
139
|
+
? meta['__outOfScopeGoalIds']
|
|
140
|
+
: Array.isArray(meta['__outOfScopeGoalIds'])
|
|
141
|
+
? new Set(meta['__outOfScopeGoalIds'])
|
|
142
|
+
: undefined;
|
|
143
|
+
const outOfScopeFactorIds = meta['__outOfScopeFactorIds'] instanceof Set
|
|
144
|
+
? meta['__outOfScopeFactorIds']
|
|
145
|
+
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
146
|
+
? new Set(meta['__outOfScopeFactorIds'])
|
|
147
|
+
: undefined;
|
|
148
|
+
const v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
127
149
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
128
150
|
r.errors.push(...v.errors);
|
|
129
151
|
r.warnings.push(...v.warnings);
|
|
130
152
|
if (v.valid && v.parsed) {
|
|
131
|
-
r.svg = renderFgcaSvg(v.parsed, { variant: 'dga' });
|
|
153
|
+
r.svg = renderFgcaSvg(v.parsed, { variant: 'dga', scopeCaption: v.scopeCaption });
|
|
132
154
|
}
|
|
133
155
|
return r;
|
|
134
156
|
}
|
|
@@ -41,6 +41,8 @@ export interface RenderFgcaOptions {
|
|
|
41
41
|
entryCurvature?: number;
|
|
42
42
|
nodeSizePreset?: NodeSizePreset;
|
|
43
43
|
layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
|
|
44
|
+
/** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
|
|
45
|
+
scopeCaption?: boolean;
|
|
44
46
|
}
|
|
45
47
|
export declare function renderFgcaSvg(doc: FGCADoc, options?: RenderFgcaOptions): string;
|
|
46
48
|
export {};
|
|
@@ -66,7 +66,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
|
|
|
66
66
|
return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
|
|
67
67
|
}
|
|
68
68
|
export function renderFgcaSvg(doc, options = {}) {
|
|
69
|
-
const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, nodeSizePreset = 'normal', layoutOptions, } = options;
|
|
69
|
+
const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, nodeSizePreset = 'normal', layoutOptions, scopeCaption = false, } = options;
|
|
70
70
|
const hideChanges = variant === 'dga' || doc.hideChanges === true;
|
|
71
71
|
const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
|
|
72
72
|
const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, {
|
|
@@ -85,8 +85,13 @@ export function renderFgcaSvg(doc, options = {}) {
|
|
|
85
85
|
const titleSvg = title
|
|
86
86
|
? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
|
|
87
87
|
: '';
|
|
88
|
+
const captionSvg = scopeCaption
|
|
89
|
+
? `<text class="text-caption" x="${PAD}" y="${height + 16}">${escXml('This diagram is shown for the selected goal scope. Actions on it may also serve goals outside that scope.')}</text>`
|
|
90
|
+
: '';
|
|
88
91
|
const embedCss = generateSvgEmbedCss('transitrix');
|
|
89
|
-
|
|
92
|
+
// Calculate final height including caption if present
|
|
93
|
+
const finalHeight = scopeCaption ? height + 24 : height;
|
|
94
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${finalHeight}" viewBox="0 0 ${width} ${finalHeight}">
|
|
90
95
|
<style>${embedCss}</style>
|
|
91
96
|
<defs>
|
|
92
97
|
<marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
|
|
@@ -95,5 +100,6 @@ export function renderFgcaSvg(doc, options = {}) {
|
|
|
95
100
|
</defs>
|
|
96
101
|
${titleSvg}
|
|
97
102
|
${body}
|
|
103
|
+
${captionSvg}
|
|
98
104
|
</svg>`;
|
|
99
105
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transitrix/diagrams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,12 +55,12 @@
|
|
|
55
55
|
"@testing-library/dom": "^10.4.1",
|
|
56
56
|
"@testing-library/react": "^16.3.2",
|
|
57
57
|
"@types/react": "^19.2.17",
|
|
58
|
-
"@types/react-dom": "^19.2.
|
|
58
|
+
"@types/react-dom": "^19.2.5",
|
|
59
59
|
"jsdom": "^29.1.1",
|
|
60
60
|
"react": "^19.2.8",
|
|
61
61
|
"react-dom": "^19.2.8",
|
|
62
62
|
"reactflow": "^11.11.4",
|
|
63
63
|
"typescript": "^5.0.0",
|
|
64
|
-
"vitest": "^4.1.
|
|
64
|
+
"vitest": "^4.1.11"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -216,8 +216,9 @@ export function validateActivities(input: unknown): ActivityValidationResult {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
// ACT-011: warn if no duration (duration_days is accepted as an alias)
|
|
219
|
+
// Network view is unaffected; this is only about CPM/Gantt rendering.
|
|
219
220
|
if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
|
|
220
|
-
warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration —
|
|
221
|
+
warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — Gantt view and CPM analysis will exclude it. Network view is unaffected.`, path });
|
|
221
222
|
}
|
|
222
223
|
}
|
|
223
224
|
|
|
@@ -292,7 +293,9 @@ export function validateActivities(input: unknown): ActivityValidationResult {
|
|
|
292
293
|
}
|
|
293
294
|
}
|
|
294
295
|
|
|
295
|
-
// ACT-013: orphan activities (warning only)
|
|
296
|
+
// ACT-013: orphan activities (warning only). An activity is structurally orphan
|
|
297
|
+
// if it has no connections to the rest of the graph. Valid connections are:
|
|
298
|
+
// predecessors, successors, parent, or goals (direct or via delivers_changes).
|
|
296
299
|
const successorIds = new Set<string>();
|
|
297
300
|
for (const a of doc.activities) {
|
|
298
301
|
for (const pred of (a.predecessors ?? [])) successorIds.add(pred);
|
|
@@ -302,10 +305,11 @@ export function validateActivities(input: unknown): ActivityValidationResult {
|
|
|
302
305
|
const hasSuccessors = successorIds.has(a.id);
|
|
303
306
|
const hasGoals = Array.isArray(a.goals) && a.goals.length > 0;
|
|
304
307
|
const hasPredecessors = Array.isArray(a.predecessors) && a.predecessors.length > 0;
|
|
305
|
-
|
|
308
|
+
const hasParent = typeof a.parent === 'string' && a.parent.length > 0;
|
|
309
|
+
if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors && !hasParent) {
|
|
306
310
|
warnings.push({
|
|
307
311
|
code: 'ACT-013',
|
|
308
|
-
message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, or goals)`,
|
|
312
|
+
message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, parent, or goals)`,
|
|
309
313
|
path: `activities[${i}]`,
|
|
310
314
|
});
|
|
311
315
|
}
|
|
@@ -17,6 +17,7 @@ export interface CapabilityCardNodeData {
|
|
|
17
17
|
maturityColours: Record<number, string>;
|
|
18
18
|
readOnly: boolean;
|
|
19
19
|
isDropTarget: boolean;
|
|
20
|
+
hasChildren: boolean;
|
|
20
21
|
hasHiddenChildren: boolean;
|
|
21
22
|
isCollapsed: boolean;
|
|
22
23
|
onAddChild: (parentId: number) => void;
|
|
@@ -25,7 +26,7 @@ export interface CapabilityCardNodeData {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const CapabilityCardNode = memo(({ data }: { data: CapabilityCardNodeData }) => {
|
|
28
|
-
const { capability, theme, maturityColours, readOnly, isDropTarget, hasHiddenChildren, isCollapsed } = data;
|
|
29
|
+
const { capability, theme, maturityColours, readOnly, isDropTarget, hasChildren, hasHiddenChildren, isCollapsed } = data;
|
|
29
30
|
const maturity = latestMaturityLevel(capability);
|
|
30
31
|
const dotColour = maturity !== undefined ? maturityColours[maturity] : undefined;
|
|
31
32
|
|
|
@@ -119,11 +120,11 @@ const CapabilityCardNode = memo(({ data }: { data: CapabilityCardNodeData }) =>
|
|
|
119
120
|
</button>
|
|
120
121
|
)}
|
|
121
122
|
|
|
122
|
-
{hasHiddenChildren && (
|
|
123
|
+
{(hasChildren || hasHiddenChildren) && (
|
|
123
124
|
<button
|
|
124
125
|
type="button"
|
|
125
|
-
aria-label={isCollapsed ? 'Expand branch' : 'Collapse branch'}
|
|
126
|
-
title={isCollapsed ? 'Expand branch' : 'Collapse branch'}
|
|
126
|
+
aria-label={isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch'}
|
|
127
|
+
title={isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch'}
|
|
127
128
|
onClick={(e) => {
|
|
128
129
|
e.stopPropagation();
|
|
129
130
|
data.onToggleCollapse(capability.id);
|
|
@@ -131,10 +132,11 @@ const CapabilityCardNode = memo(({ data }: { data: CapabilityCardNodeData }) =>
|
|
|
131
132
|
style={{
|
|
132
133
|
position: 'absolute', top: '50%', right: -8, transform: 'translateY(-50%)',
|
|
133
134
|
width: 16, height: 16, borderRadius: '50%', border: `1px solid ${theme.cardBorderColor}`,
|
|
134
|
-
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
135
|
+
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
136
|
+
cursor: 'pointer', zIndex: 10,
|
|
135
137
|
}}
|
|
136
138
|
>
|
|
137
|
-
{isCollapsed ? '+' : '−'}
|
|
139
|
+
{isCollapsed || hasHiddenChildren ? '+' : '−'}
|
|
138
140
|
</button>
|
|
139
141
|
)}
|
|
140
142
|
</div>
|
|
@@ -79,12 +79,13 @@ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions =
|
|
|
79
79
|
const cap = byId.get(id)!;
|
|
80
80
|
const allChildIds = children.get(id) ?? [];
|
|
81
81
|
const childIds = allChildIds.filter((c) => !hiddenSubtrees.has(c));
|
|
82
|
+
const hasChildren = allChildIds.length > 0;
|
|
82
83
|
const hasHiddenChildren = childIds.length < allChildIds.length;
|
|
83
84
|
|
|
84
85
|
if (childIds.length === 0) {
|
|
85
86
|
const y = getNextY(col);
|
|
86
87
|
advanceY(col, nodeHeight + nodeSep);
|
|
87
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
|
|
88
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasChildren, hasHiddenChildren });
|
|
88
89
|
return { top: y, bottom: y + nodeHeight };
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -96,7 +97,7 @@ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions =
|
|
|
96
97
|
const finalY = Math.max(parentY, curColY);
|
|
97
98
|
advanceY(col, finalY - curColY + nodeHeight + nodeSep);
|
|
98
99
|
|
|
99
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
|
|
100
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasChildren, hasHiddenChildren });
|
|
100
101
|
for (const cid of childIds) edges.push({ source: id, target: cid });
|
|
101
102
|
return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + nodeHeight, spanBottom) };
|
|
102
103
|
}
|
|
@@ -108,7 +109,7 @@ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions =
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
if (nodes.length === 0) {
|
|
111
|
-
const rootNode: LaidOutNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
|
|
112
|
+
const rootNode: LaidOutNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasChildren: false, hasHiddenChildren: false };
|
|
112
113
|
return { rootNode, nodes: [], edges: [], bounds: { x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight } };
|
|
113
114
|
}
|
|
114
115
|
|
|
@@ -122,7 +123,7 @@ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions =
|
|
|
122
123
|
const rootTop = Math.min(...l1Nodes.map((n) => n.y));
|
|
123
124
|
const rootBottom = Math.max(...l1Nodes.map((n) => n.y + n.height));
|
|
124
125
|
const rootY = rootTop + (rootBottom - rootTop) / 2 - nodeHeight / 2;
|
|
125
|
-
const rootNode: LaidOutNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
|
|
126
|
+
const rootNode: LaidOutNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasChildren: l1Ids.length > 0, hasHiddenChildren: false };
|
|
126
127
|
|
|
127
128
|
const rootEdges: LaidOutEdge[] = l1Ids
|
|
128
129
|
.filter((id) => !hiddenSubtrees.has(id))
|
|
@@ -41,6 +41,8 @@ import { actionChangeLinkField } from './action-change-ids.js';
|
|
|
41
41
|
export interface CanonicalFGCAResult extends ValidationResult {
|
|
42
42
|
/** On success, the internal-form FGCADoc derived from the canonical input. */
|
|
43
43
|
parsed?: FGCADoc;
|
|
44
|
+
/** True when a goal-scoped projection should show the scope caption. */
|
|
45
|
+
scopeCaption?: boolean;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
// Canonical ID grammars per IDS_AND_REFERENCES.md §1. The TYPE prefix
|
|
@@ -79,8 +81,20 @@ export function isDgcaChangesLayerOff(input: unknown): boolean {
|
|
|
79
81
|
/**
|
|
80
82
|
* Validate canonical FGCA YAML and, on success, return an internal-form
|
|
81
83
|
* `FGCADoc` ready for `buildFGCALayout`.
|
|
84
|
+
*
|
|
85
|
+
* @param input The FGCA document to parse
|
|
86
|
+
* @param outOfScopeGoalIds When a goal-scoped projection omits goals that exist
|
|
87
|
+
* in the catalogue, pass their IDs here to suppress FGCA-011/FGCA-009 errors
|
|
88
|
+
* for references to out-of-scope goals. When provided and non-empty, and at least
|
|
89
|
+
* one action or change references an out-of-scope goal, `scopeCaption` is set true.
|
|
90
|
+
* @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
|
|
91
|
+
* in the catalogue, pass their IDs here to suppress FGCA-008 errors.
|
|
82
92
|
*/
|
|
83
|
-
export function parseCanonicalFGCA(
|
|
93
|
+
export function parseCanonicalFGCA(
|
|
94
|
+
input: unknown,
|
|
95
|
+
outOfScopeGoalIds?: Set<string> | null,
|
|
96
|
+
outOfScopeFactorIds?: Set<string> | null,
|
|
97
|
+
): CanonicalFGCAResult {
|
|
84
98
|
const errors: ValidationError[] = [];
|
|
85
99
|
const warnings: ValidationWarning[] = [];
|
|
86
100
|
|
|
@@ -204,6 +218,9 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
204
218
|
|
|
205
219
|
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
206
220
|
|
|
221
|
+
// Track if any out-of-scope goals/factors are referenced (for scope caption).
|
|
222
|
+
let hasOutOfScopeRef = false;
|
|
223
|
+
|
|
207
224
|
// Cross-refs.
|
|
208
225
|
function checkRefArray(
|
|
209
226
|
el: Record<string, unknown>,
|
|
@@ -212,6 +229,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
212
229
|
refRe: RegExp,
|
|
213
230
|
code: string,
|
|
214
231
|
path: string,
|
|
232
|
+
outOfScope?: Set<string> | null,
|
|
215
233
|
): string[] {
|
|
216
234
|
const ref = el[field];
|
|
217
235
|
if (ref === undefined || ref === null) return [];
|
|
@@ -229,6 +247,12 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
229
247
|
errors.push({ code: 'FGCA-007', message: `${path}.${field}[] entry "${r}" does not match the expected grammar`, path });
|
|
230
248
|
continue;
|
|
231
249
|
}
|
|
250
|
+
if (outOfScope?.has(r)) {
|
|
251
|
+
// Reference is out-of-scope: suppress error and mark that we found an out-of-scope reference (for caption).
|
|
252
|
+
hasOutOfScopeRef = true;
|
|
253
|
+
out.push(r);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
232
256
|
if (!targets.has(r)) {
|
|
233
257
|
errors.push({ code, message: `${path}.${field}[] references undeclared element "${r}"`, path });
|
|
234
258
|
continue;
|
|
@@ -247,7 +271,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
247
271
|
}));
|
|
248
272
|
|
|
249
273
|
const internalGoals: GoalItem[] = goals.map((el, i) => {
|
|
250
|
-
const refIds = checkRefArray(el!, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]
|
|
274
|
+
const refIds = checkRefArray(el!, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`, outOfScopeFactorIds);
|
|
251
275
|
return {
|
|
252
276
|
id: String(el!['id']),
|
|
253
277
|
name: String(el!['name'] ?? ''),
|
|
@@ -275,7 +299,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
275
299
|
}
|
|
276
300
|
|
|
277
301
|
const internalChanges: BdnChangeWithActivities[] = changes.map((el, i) => {
|
|
278
|
-
const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-009', `changes[${i}]
|
|
302
|
+
const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-009', `changes[${i}]`, outOfScopeGoalIds);
|
|
279
303
|
// Internal `change.goal_id` is singular; canonical `change.goals` is plural.
|
|
280
304
|
// First goal wins for the singular field; warn (not error) if multiple.
|
|
281
305
|
if (goalRefs.length > 1) {
|
|
@@ -296,7 +320,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
296
320
|
});
|
|
297
321
|
|
|
298
322
|
const internalActivities: ActivityItem[] = activities.map((el, i) => {
|
|
299
|
-
const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]
|
|
323
|
+
const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`, outOfScopeGoalIds);
|
|
300
324
|
const typeVal = typeof el!['type'] === 'string' ? el!['type'] : undefined;
|
|
301
325
|
return {
|
|
302
326
|
id: String(el!['id']),
|
|
@@ -337,7 +361,13 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
337
361
|
hideChanges: isDgcaChangesLayerOff(raw),
|
|
338
362
|
};
|
|
339
363
|
|
|
340
|
-
return {
|
|
364
|
+
return {
|
|
365
|
+
valid: true,
|
|
366
|
+
errors,
|
|
367
|
+
warnings,
|
|
368
|
+
parsed,
|
|
369
|
+
scopeCaption: (outOfScopeGoalIds?.size ?? 0) > 0 && hasOutOfScopeRef,
|
|
370
|
+
};
|
|
341
371
|
}
|
|
342
372
|
|
|
343
373
|
const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
@@ -345,6 +375,8 @@ const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
|
345
375
|
/** On success, the internal-form FGCADoc derived from canonical FGA input. */
|
|
346
376
|
export interface CanonicalFGAResult extends ValidationResult {
|
|
347
377
|
parsed?: FGCADoc;
|
|
378
|
+
/** True when a goal-scoped projection should show the scope caption. */
|
|
379
|
+
scopeCaption?: boolean;
|
|
348
380
|
}
|
|
349
381
|
|
|
350
382
|
/**
|
|
@@ -364,7 +396,11 @@ export interface CanonicalFGAResult extends ValidationResult {
|
|
|
364
396
|
* the canonical FGA path is unit-testable and the library owns the single
|
|
365
397
|
* canon shape — flat form only, no `fga:` wrapper.
|
|
366
398
|
*/
|
|
367
|
-
export function parseCanonicalFGA(
|
|
399
|
+
export function parseCanonicalFGA(
|
|
400
|
+
input: unknown,
|
|
401
|
+
outOfScopeGoalIds?: Set<string> | null,
|
|
402
|
+
outOfScopeFactorIds?: Set<string> | null,
|
|
403
|
+
): CanonicalFGAResult {
|
|
368
404
|
if (!input || typeof input !== 'object') {
|
|
369
405
|
return { valid: false, errors: [{ code: 'FGA-001', message: 'document root is not an object' }], warnings: [] };
|
|
370
406
|
}
|
|
@@ -392,7 +428,7 @@ export function parseCanonicalFGA(input: unknown): CanonicalFGAResult {
|
|
|
392
428
|
// codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
|
|
393
429
|
// are unreachable here because `changes` is empty.
|
|
394
430
|
const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [] };
|
|
395
|
-
const r = parseCanonicalFGCA(synth);
|
|
431
|
+
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
396
432
|
const remap: Record<string, string> = {
|
|
397
433
|
'FGCA-001': 'FGA-001',
|
|
398
434
|
'FGCA-002': 'FGA-002',
|
|
@@ -412,5 +448,6 @@ export function parseCanonicalFGA(input: unknown): CanonicalFGAResult {
|
|
|
412
448
|
errors: r.errors.map((e) => ({ ...e, code: remapCode(e.code) })),
|
|
413
449
|
warnings: r.warnings.map((w) => ({ ...w, code: remapCode(w.code) })),
|
|
414
450
|
parsed: r.parsed,
|
|
451
|
+
scopeCaption: r.scopeCaption,
|
|
415
452
|
};
|
|
416
453
|
}
|
package/src/fgca/resolver.ts
CHANGED
|
@@ -158,6 +158,28 @@ export function resolveFGCA(
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// Collect out-of-scope goals (exist in catalogue but not in selected set).
|
|
162
|
+
// Used by the validator to suppress errors for references to goals that exist
|
|
163
|
+
// but are outside the scope.
|
|
164
|
+
let outOfScopeGoalIds: Set<string> | undefined;
|
|
165
|
+
if (goalsFilter !== 'all') {
|
|
166
|
+
// Goal scope is restricted; collect goals that exist but aren't selected.
|
|
167
|
+
outOfScopeGoalIds = new Set(
|
|
168
|
+
[...goalElems.keys()].filter((id) => !selectedGoalIds.has(id)),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Collect out-of-scope factors (exist in catalogue but not in selected set).
|
|
173
|
+
const selectedFactorIds = new Set(
|
|
174
|
+
selectedFactors.map((f) => str(f['id'])).filter((x): x is string => x !== undefined),
|
|
175
|
+
);
|
|
176
|
+
let outOfScopeFactorIds: Set<string> | undefined;
|
|
177
|
+
if (goalsFilter !== 'all') {
|
|
178
|
+
outOfScopeFactorIds = new Set(
|
|
179
|
+
[...factorElems.keys()].filter((id) => !selectedFactorIds.has(id)),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
161
183
|
return {
|
|
162
184
|
notation: 'dgca',
|
|
163
185
|
id: viewDoc['id'],
|
|
@@ -168,5 +190,7 @@ export function resolveFGCA(
|
|
|
168
190
|
changes: selectedChanges,
|
|
169
191
|
actions: selectedActivities,
|
|
170
192
|
view_config: viewDoc['view_config'],
|
|
193
|
+
__outOfScopeGoalIds: outOfScopeGoalIds,
|
|
194
|
+
__outOfScopeFactorIds: outOfScopeFactorIds,
|
|
171
195
|
};
|
|
172
196
|
}
|
package/src/goals/GoalNode.tsx
CHANGED
|
@@ -19,6 +19,7 @@ export interface GoalNodeData {
|
|
|
19
19
|
theme: Required<ThemeTokens>;
|
|
20
20
|
readOnly: boolean;
|
|
21
21
|
isDropTarget: boolean;
|
|
22
|
+
hasChildren: boolean;
|
|
22
23
|
hasHiddenChildren: boolean;
|
|
23
24
|
isCollapsed: boolean;
|
|
24
25
|
onAddChild: (parentId: number, parentLevel: number) => void;
|
|
@@ -28,7 +29,7 @@ export interface GoalNodeData {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
31
|
-
const { goal, theme, readOnly, isDropTarget, hasHiddenChildren, isCollapsed } = data;
|
|
32
|
+
const { goal, theme, readOnly, isDropTarget, hasChildren, hasHiddenChildren, isCollapsed } = data;
|
|
32
33
|
const [openPanel, setOpenPanel] = useState<'positive' | 'negative' | null>(null);
|
|
33
34
|
|
|
34
35
|
const positive = (goal.factors ?? []).filter((f) => impactSign(f) !== 'negative');
|
|
@@ -192,11 +193,11 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
|
192
193
|
</button>
|
|
193
194
|
)}
|
|
194
195
|
|
|
195
|
-
{hasHiddenChildren && (
|
|
196
|
+
{(hasChildren || hasHiddenChildren) && (
|
|
196
197
|
<button
|
|
197
198
|
type="button"
|
|
198
|
-
aria-label={isCollapsed ? 'Expand branch' : 'Collapse branch'}
|
|
199
|
-
title={isCollapsed ? 'Expand branch' : 'Collapse branch'}
|
|
199
|
+
aria-label={isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch'}
|
|
200
|
+
title={isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch'}
|
|
200
201
|
onClick={(e) => {
|
|
201
202
|
e.stopPropagation();
|
|
202
203
|
data.onToggleCollapse(goal.id);
|
|
@@ -204,10 +205,11 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
|
204
205
|
style={{
|
|
205
206
|
position: 'absolute', top: '50%', right: -8, transform: 'translateY(-50%)',
|
|
206
207
|
width: 16, height: 16, borderRadius: '50%', border: `1px solid ${theme.cardBorderColor}`,
|
|
207
|
-
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
208
|
+
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
209
|
+
cursor: 'pointer', zIndex: 10,
|
|
208
210
|
}}
|
|
209
211
|
>
|
|
210
|
-
{isCollapsed ? '+' : '−'}
|
|
212
|
+
{isCollapsed || hasHiddenChildren ? '+' : '−'}
|
|
211
213
|
</button>
|
|
212
214
|
)}
|
|
213
215
|
</div>
|
package/src/goals/layout.ts
CHANGED
|
@@ -114,15 +114,17 @@ export function layoutGoalTree(tree: GoalTree, options: LayoutOptions = {}): Goa
|
|
|
114
114
|
placed.add(id);
|
|
115
115
|
|
|
116
116
|
const col = levelToCol.get(goal.level) ?? 0;
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
117
|
+
const allChildIds = children.get(id) ?? [];
|
|
118
|
+
const childIds = allChildIds.filter(c => !hiddenSubtrees.has(c) && !placed.has(c));
|
|
119
|
+
const isCollapsedRoot = hiddenSet.has(id) && allChildIds.length > 0;
|
|
120
|
+
const hasChildren = allChildIds.length > 0;
|
|
121
|
+
const hasHiddenChildren = childIds.length < allChildIds.length;
|
|
120
122
|
|
|
121
123
|
if (childIds.length === 0) {
|
|
122
124
|
// Leaf: place at current column Y
|
|
123
125
|
const y = getNextY(col);
|
|
124
126
|
advanceY(col, nodeHeight + nodeSep);
|
|
125
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasHiddenChildren });
|
|
127
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasChildren, hasHiddenChildren });
|
|
126
128
|
return { top: y, bottom: y + nodeHeight };
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -141,7 +143,7 @@ export function layoutGoalTree(tree: GoalTree, options: LayoutOptions = {}): Goa
|
|
|
141
143
|
const finalY = Math.max(parentY, curColY);
|
|
142
144
|
advanceY(col, finalY - curColY + nodeHeight + nodeSep);
|
|
143
145
|
|
|
144
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasHiddenChildren });
|
|
146
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasChildren, hasHiddenChildren });
|
|
145
147
|
|
|
146
148
|
for (const cid of childIds) {
|
|
147
149
|
edges.push({ source: id, target: cid });
|
package/src/goals/types.ts
CHANGED
package/src/webview/entry.ts
CHANGED
|
@@ -187,22 +187,44 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
187
187
|
return r;
|
|
188
188
|
}
|
|
189
189
|
case 'dgca': {
|
|
190
|
-
const
|
|
190
|
+
const meta = (doc ?? {}) as Record<string, unknown>;
|
|
191
|
+
const outOfScopeGoalIds = meta['__outOfScopeGoalIds'] instanceof Set
|
|
192
|
+
? (meta['__outOfScopeGoalIds'] as Set<string>)
|
|
193
|
+
: Array.isArray(meta['__outOfScopeGoalIds'])
|
|
194
|
+
? new Set(meta['__outOfScopeGoalIds'] as string[])
|
|
195
|
+
: undefined;
|
|
196
|
+
const outOfScopeFactorIds = meta['__outOfScopeFactorIds'] instanceof Set
|
|
197
|
+
? (meta['__outOfScopeFactorIds'] as Set<string>)
|
|
198
|
+
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
199
|
+
? new Set(meta['__outOfScopeFactorIds'] as string[])
|
|
200
|
+
: undefined;
|
|
201
|
+
const v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
191
202
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
192
203
|
r.errors.push(...v.errors);
|
|
193
204
|
r.warnings.push(...v.warnings);
|
|
194
205
|
if (v.valid && v.parsed) {
|
|
195
|
-
r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca' });
|
|
206
|
+
r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca', scopeCaption: v.scopeCaption });
|
|
196
207
|
}
|
|
197
208
|
return r;
|
|
198
209
|
}
|
|
199
210
|
case 'dga': {
|
|
200
|
-
const
|
|
211
|
+
const meta = (doc ?? {}) as Record<string, unknown>;
|
|
212
|
+
const outOfScopeGoalIds = meta['__outOfScopeGoalIds'] instanceof Set
|
|
213
|
+
? (meta['__outOfScopeGoalIds'] as Set<string>)
|
|
214
|
+
: Array.isArray(meta['__outOfScopeGoalIds'])
|
|
215
|
+
? new Set(meta['__outOfScopeGoalIds'] as string[])
|
|
216
|
+
: undefined;
|
|
217
|
+
const outOfScopeFactorIds = meta['__outOfScopeFactorIds'] instanceof Set
|
|
218
|
+
? (meta['__outOfScopeFactorIds'] as Set<string>)
|
|
219
|
+
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
220
|
+
? new Set(meta['__outOfScopeFactorIds'] as string[])
|
|
221
|
+
: undefined;
|
|
222
|
+
const v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
201
223
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
202
224
|
r.errors.push(...v.errors);
|
|
203
225
|
r.warnings.push(...v.warnings);
|
|
204
226
|
if (v.valid && v.parsed) {
|
|
205
|
-
r.svg = renderFgcaSvg(v.parsed, { variant: 'dga' });
|
|
227
|
+
r.svg = renderFgcaSvg(v.parsed, { variant: 'dga', scopeCaption: v.scopeCaption });
|
|
206
228
|
}
|
|
207
229
|
return r;
|
|
208
230
|
}
|
|
@@ -110,6 +110,8 @@ export interface RenderFgcaOptions {
|
|
|
110
110
|
entryCurvature?: number;
|
|
111
111
|
nodeSizePreset?: NodeSizePreset;
|
|
112
112
|
layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
|
|
113
|
+
/** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
|
|
114
|
+
scopeCaption?: boolean;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): string {
|
|
@@ -120,6 +122,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
120
122
|
entryCurvature,
|
|
121
123
|
nodeSizePreset = 'normal',
|
|
122
124
|
layoutOptions,
|
|
125
|
+
scopeCaption = false,
|
|
123
126
|
} = options;
|
|
124
127
|
const hideChanges = variant === 'dga' || doc.hideChanges === true;
|
|
125
128
|
const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
|
|
@@ -144,9 +147,16 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
144
147
|
? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
|
|
145
148
|
: '';
|
|
146
149
|
|
|
150
|
+
const captionSvg = scopeCaption
|
|
151
|
+
? `<text class="text-caption" x="${PAD}" y="${height + 16}">${escXml('This diagram is shown for the selected goal scope. Actions on it may also serve goals outside that scope.')}</text>`
|
|
152
|
+
: '';
|
|
153
|
+
|
|
147
154
|
const embedCss = generateSvgEmbedCss('transitrix');
|
|
148
155
|
|
|
149
|
-
|
|
156
|
+
// Calculate final height including caption if present
|
|
157
|
+
const finalHeight = scopeCaption ? height + 24 : height;
|
|
158
|
+
|
|
159
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${finalHeight}" viewBox="0 0 ${width} ${finalHeight}">
|
|
150
160
|
<style>${embedCss}</style>
|
|
151
161
|
<defs>
|
|
152
162
|
<marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
|
|
@@ -155,5 +165,6 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
155
165
|
</defs>
|
|
156
166
|
${titleSvg}
|
|
157
167
|
${body}
|
|
168
|
+
${captionSvg}
|
|
158
169
|
</svg>`;
|
|
159
170
|
}
|