@transitrix/diagrams 1.12.2 → 1.13.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/fgca/parse-canonical.d.ts +10 -2
- package/dist/fgca/parse-canonical.js +9 -4
- package/dist/fgca/preview-layout.d.ts +13 -0
- package/dist/fgca/preview-layout.js +37 -12
- package/dist/fgca/resolver.d.ts +4 -0
- package/dist/fgca/resolver.js +1 -0
- package/dist/fgca/types.d.ts +4 -0
- package/dist/theme/themes.js +3 -1
- package/dist/theme/tokens.d.ts +12 -0
- package/dist/theme/tokens.js +12 -0
- package/dist/webview/entry.js +8 -2
- package/dist/webview/render-fgca.d.ts +3 -0
- package/dist/webview/render-fgca.js +31 -3
- package/dist/webview/render-goals.js +5 -0
- package/package.json +2 -2
- package/src/fgca/parse-canonical.ts +9 -2
- package/src/fgca/preview-layout.ts +53 -14
- package/src/fgca/resolver.ts +2 -0
- package/src/fgca/types.ts +4 -0
- package/src/theme/themes.ts +3 -0
- package/src/theme/tokens.ts +13 -0
- package/src/webview/entry.ts +8 -2
- package/src/webview/render-fgca.ts +34 -2
- package/src/webview/render-goals.ts +4 -0
|
@@ -44,8 +44,13 @@ export declare function isDgcaChangesLayerOff(input: unknown): boolean;
|
|
|
44
44
|
* one action or change references an out-of-scope goal, `scopeCaption` is set true.
|
|
45
45
|
* @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
|
|
46
46
|
* in the catalogue, pass their IDs here to suppress FGCA-008 errors.
|
|
47
|
+
* @param progressData Optional map of action ID to progress data (percent + computedAt timestamp).
|
|
48
|
+
* Consumed from `__progressData` metadata field if provided there.
|
|
47
49
|
*/
|
|
48
|
-
export declare function parseCanonicalFGCA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null
|
|
50
|
+
export declare function parseCanonicalFGCA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null, progressData?: Map<string, {
|
|
51
|
+
percent: number;
|
|
52
|
+
computedAt: string;
|
|
53
|
+
}> | null): CanonicalFGCAResult;
|
|
49
54
|
/** On success, the internal-form FGCADoc derived from canonical FGA input. */
|
|
50
55
|
export interface CanonicalFGAResult extends ValidationResult {
|
|
51
56
|
parsed?: FGCADoc;
|
|
@@ -69,4 +74,7 @@ export interface CanonicalFGAResult extends ValidationResult {
|
|
|
69
74
|
* the canonical FGA path is unit-testable and the library owns the single
|
|
70
75
|
* canon shape — flat form only, no `fga:` wrapper.
|
|
71
76
|
*/
|
|
72
|
-
export declare function parseCanonicalFGA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null
|
|
77
|
+
export declare function parseCanonicalFGA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null, progressData?: Map<string, {
|
|
78
|
+
percent: number;
|
|
79
|
+
computedAt: string;
|
|
80
|
+
}> | null): CanonicalFGAResult;
|
|
@@ -67,8 +67,10 @@ export function isDgcaChangesLayerOff(input) {
|
|
|
67
67
|
* one action or change references an out-of-scope goal, `scopeCaption` is set true.
|
|
68
68
|
* @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
|
|
69
69
|
* in the catalogue, pass their IDs here to suppress FGCA-008 errors.
|
|
70
|
+
* @param progressData Optional map of action ID to progress data (percent + computedAt timestamp).
|
|
71
|
+
* Consumed from `__progressData` metadata field if provided there.
|
|
70
72
|
*/
|
|
71
|
-
export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds) {
|
|
73
|
+
export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds, progressData) {
|
|
72
74
|
const errors = [];
|
|
73
75
|
const warnings = [];
|
|
74
76
|
if (!input || typeof input !== 'object') {
|
|
@@ -271,11 +273,14 @@ export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds
|
|
|
271
273
|
const internalActivities = activities.map((el, i) => {
|
|
272
274
|
const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`, outOfScopeGoalIds);
|
|
273
275
|
const typeVal = typeof el['type'] === 'string' ? el['type'] : undefined;
|
|
276
|
+
const actId = String(el['id']);
|
|
277
|
+
const progressVal = progressData?.get(actId);
|
|
274
278
|
return {
|
|
275
|
-
id:
|
|
279
|
+
id: actId,
|
|
276
280
|
name: String(el['name'] ?? ''),
|
|
277
281
|
goal_id: goalRefs.length > 0 ? goalRefs[0] : null,
|
|
278
282
|
...(typeVal !== undefined ? { type: typeVal } : {}),
|
|
283
|
+
...(progressVal !== undefined ? { progress: progressVal } : {}),
|
|
279
284
|
};
|
|
280
285
|
});
|
|
281
286
|
// FACTOR.references_constraint grammar check (FGCA-015).
|
|
@@ -334,7 +339,7 @@ const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
|
334
339
|
* the canonical FGA path is unit-testable and the library owns the single
|
|
335
340
|
* canon shape — flat form only, no `fga:` wrapper.
|
|
336
341
|
*/
|
|
337
|
-
export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds) {
|
|
342
|
+
export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds, progressData) {
|
|
338
343
|
if (!input || typeof input !== 'object') {
|
|
339
344
|
return { valid: false, errors: [{ code: 'FGA-001', message: 'document root is not an object' }], warnings: [] };
|
|
340
345
|
}
|
|
@@ -361,7 +366,7 @@ export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds)
|
|
|
361
366
|
// codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
|
|
362
367
|
// are unreachable here because `changes` is empty.
|
|
363
368
|
const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [] };
|
|
364
|
-
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
369
|
+
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
365
370
|
const remap = {
|
|
366
371
|
'FGCA-001': 'FGA-001',
|
|
367
372
|
'FGCA-002': 'FGA-002',
|
|
@@ -22,6 +22,10 @@ export interface FGCAPreviewActivity {
|
|
|
22
22
|
id: number | string;
|
|
23
23
|
name: string;
|
|
24
24
|
goal_id?: number | string | null;
|
|
25
|
+
progress?: {
|
|
26
|
+
percent: number;
|
|
27
|
+
computedAt: string;
|
|
28
|
+
};
|
|
25
29
|
}
|
|
26
30
|
/** Structural input the preview layout needs — a subset of the parsed DGCA/DGA doc. */
|
|
27
31
|
export interface FGCAPreviewDoc {
|
|
@@ -83,6 +87,15 @@ export interface FGCAPreviewNode {
|
|
|
83
87
|
y: number;
|
|
84
88
|
label: string;
|
|
85
89
|
col: FGCAPreviewColumn;
|
|
90
|
+
type?: string;
|
|
91
|
+
progress?: {
|
|
92
|
+
percent: number;
|
|
93
|
+
computedAt: string;
|
|
94
|
+
};
|
|
95
|
+
/** Marks a synthesized node (e.g. virtual Change) vs. a real entity. */
|
|
96
|
+
virtual?: boolean;
|
|
97
|
+
/** For activity nodes: true if the activity is linked to a goal. */
|
|
98
|
+
linked?: boolean;
|
|
86
99
|
}
|
|
87
100
|
export interface FGCAPreviewEdge {
|
|
88
101
|
sx: number;
|
|
@@ -232,11 +232,24 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
|
|
|
232
232
|
? ['driver', 'goal', 'activity']
|
|
233
233
|
: ['driver', 'goal', 'change', 'activity'];
|
|
234
234
|
const changes = doc.changes ?? [];
|
|
235
|
+
const coveredActivities = new Set(changes.flatMap(c => c.activity_ids.map(String)));
|
|
236
|
+
// Synthesize virtual Change nodes for activities without a real Change.
|
|
237
|
+
// Each virtual node is 1:1 with one activity (not shared across activities).
|
|
238
|
+
const virtualChanges = [];
|
|
239
|
+
for (const a of doc.activities) {
|
|
240
|
+
if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
|
|
241
|
+
const virtualId = `vchange_activity_${a.id}`;
|
|
242
|
+
virtualChanges.push({ id: virtualId, activityId: String(a.id), goalId: String(a.goal_id) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
235
245
|
const colItems = {
|
|
236
246
|
driver: doc.factors.map(f => ({ id: `driver_${f.id}`, label: f.name })),
|
|
237
247
|
goal: doc.goals.map(g => ({ id: `goal_${g.id}`, label: g.name })),
|
|
238
|
-
change:
|
|
239
|
-
|
|
248
|
+
change: [
|
|
249
|
+
...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
|
|
250
|
+
...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
|
|
251
|
+
],
|
|
252
|
+
activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: a.goal_id != null })),
|
|
240
253
|
};
|
|
241
254
|
// Build predecessor map: for each node, which node IDs in the previous column
|
|
242
255
|
// connect to it from the left. Used for barycenter crossing minimization.
|
|
@@ -248,13 +261,17 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
|
|
|
248
261
|
for (const c of changes) {
|
|
249
262
|
predecessors.set(`change_${c.id}`, [`goal_${c.goal_id}`]);
|
|
250
263
|
}
|
|
251
|
-
const
|
|
264
|
+
for (const v of virtualChanges) {
|
|
265
|
+
predecessors.set(v.id, [`goal_${v.goalId}`]);
|
|
266
|
+
}
|
|
252
267
|
for (const a of doc.activities) {
|
|
253
268
|
const preds = changes
|
|
254
269
|
.filter(c => c.activity_ids.map(String).includes(String(a.id)))
|
|
255
270
|
.map(c => `change_${c.id}`);
|
|
256
|
-
|
|
257
|
-
|
|
271
|
+
const virtualChange = virtualChanges.find(v => v.activityId === String(a.id));
|
|
272
|
+
if (virtualChange) {
|
|
273
|
+
preds.push(virtualChange.id);
|
|
274
|
+
}
|
|
258
275
|
predecessors.set(`activity_${a.id}`, preds);
|
|
259
276
|
}
|
|
260
277
|
}
|
|
@@ -295,7 +312,17 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
|
|
|
295
312
|
const items = ci === 0 ? colItems[col] : barycentricSort(colItems[col], yCenters);
|
|
296
313
|
let y = FGCA_PAD + FGCA_HEADER_H + rowGap;
|
|
297
314
|
for (const item of items) {
|
|
298
|
-
const node = {
|
|
315
|
+
const node = {
|
|
316
|
+
id: item.id,
|
|
317
|
+
x,
|
|
318
|
+
y,
|
|
319
|
+
label: item.label,
|
|
320
|
+
col,
|
|
321
|
+
type: item.type,
|
|
322
|
+
progress: item.progress,
|
|
323
|
+
virtual: item.virtual,
|
|
324
|
+
linked: item.linked,
|
|
325
|
+
};
|
|
299
326
|
nodes.push(node);
|
|
300
327
|
nodeMap.set(item.id, node);
|
|
301
328
|
yCenters.set(item.id, y + nodeHeight / 2);
|
|
@@ -334,12 +361,10 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
|
|
|
334
361
|
for (const c of changes)
|
|
335
362
|
for (const aid of c.activity_ids)
|
|
336
363
|
addEdge(`change_${c.id}`, `activity_${aid}`);
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
}
|
|
364
|
+
for (const v of virtualChanges)
|
|
365
|
+
addEdge(`goal_${v.goalId}`, v.id);
|
|
366
|
+
for (const v of virtualChanges)
|
|
367
|
+
addEdge(v.id, `activity_${v.activityId}`);
|
|
343
368
|
}
|
|
344
369
|
const maxNodeBottom = nodes.reduce((m, n) => Math.max(m, n.y + nodeHeight), FGCA_PAD + FGCA_HEADER_H + rowGap + nodeHeight);
|
|
345
370
|
const width = FGCA_PAD * 2 + cols.length * colStride - colGap;
|
package/dist/fgca/resolver.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface FGCAViewConfig {
|
|
|
33
33
|
export interface FGCACanonSources {
|
|
34
34
|
elements: unknown[];
|
|
35
35
|
relations: unknown[];
|
|
36
|
+
progressData?: Map<string, {
|
|
37
|
+
percent: number;
|
|
38
|
+
computedAt: string;
|
|
39
|
+
}>;
|
|
36
40
|
}
|
|
37
41
|
/**
|
|
38
42
|
* Returns true when the parsed view document uses the canon-projection form
|
package/dist/fgca/resolver.js
CHANGED
package/dist/fgca/types.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface ActivityItem {
|
|
|
24
24
|
activity_type_id?: number;
|
|
25
25
|
/** Canonical values: initiative | programme | project | work_package. Unknown values pass through without error. */
|
|
26
26
|
type?: string;
|
|
27
|
+
progress?: {
|
|
28
|
+
percent: number;
|
|
29
|
+
computedAt: string;
|
|
30
|
+
};
|
|
27
31
|
}
|
|
28
32
|
export interface ActivityTypeItem {
|
|
29
33
|
id: number | string;
|
package/dist/theme/themes.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, TREE_LEVEL_COLORS, TREE_MATURITY_COLORS, STRUCTURAL, BRAND_EMPHASIS, TYPOGRAPHY, CSS_VAR, getBaseResetCss, } from './tokens.js';
|
|
1
|
+
import { LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, TREE_LEVEL_COLORS, TREE_MATURITY_COLORS, VIRTUAL_ENTITY, STRUCTURAL, BRAND_EMPHASIS, TYPOGRAPHY, CSS_VAR, getBaseResetCss, } from './tokens.js';
|
|
2
2
|
const TRANSITRIX_LIGHT = {
|
|
3
3
|
bg: '#ffffff',
|
|
4
4
|
bgSurface: '#f8fafc',
|
|
@@ -79,6 +79,7 @@ function diagramVars(variant) {
|
|
|
79
79
|
`${CSS_VAR.layerGoal}:${lc.goal[variant]}`,
|
|
80
80
|
`${CSS_VAR.layerChange}:${lc.change[variant]}`,
|
|
81
81
|
`${CSS_VAR.layerActivity}:${lc.activity[variant]}`,
|
|
82
|
+
`${CSS_VAR.virtualEntity}:${VIRTUAL_ENTITY[variant]}`,
|
|
82
83
|
`${CSS_VAR.nodeStroke}:${sc.nodeStroke[variant]}`,
|
|
83
84
|
`${CSS_VAR.edgeStroke}:${sc.edgeStroke[variant]}`,
|
|
84
85
|
`${CSS_VAR.textPrimary}:${sc.textPrimary[variant]}`,
|
|
@@ -133,6 +134,7 @@ function diagramClassCss() {
|
|
|
133
134
|
.layer-goal{fill:var(${cv.layerGoal});}
|
|
134
135
|
.layer-change{fill:var(${cv.layerChange});}
|
|
135
136
|
.layer-activity{fill:var(${cv.layerActivity});}
|
|
137
|
+
.layer-virtual{fill:var(${cv.virtualEntity});stroke-dasharray:4,3;}
|
|
136
138
|
${levelRules}
|
|
137
139
|
.diagram-edge{stroke:var(${cv.edgeStroke});stroke-width:1.5;fill:none;}
|
|
138
140
|
.arrow-fill{fill:var(${cv.edgeStroke});}
|
package/dist/theme/tokens.d.ts
CHANGED
|
@@ -70,6 +70,17 @@ export declare const LAYER_COLORS: {
|
|
|
70
70
|
readonly hc: "#4ade80";
|
|
71
71
|
};
|
|
72
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Virtual/synthesized entity fill — used for placeholder nodes that represent
|
|
75
|
+
* absence (e.g. a synthesized Change box when an Action skips straight to its Goal).
|
|
76
|
+
* Pale neutral tint, distinctly muted from real entity colors; border dashed to
|
|
77
|
+
* signal non-reality.
|
|
78
|
+
*/
|
|
79
|
+
export declare const VIRTUAL_ENTITY: {
|
|
80
|
+
readonly light: "#f3f4f6";
|
|
81
|
+
readonly dark: "#2a3134";
|
|
82
|
+
readonly hc: "#e2e8f0";
|
|
83
|
+
};
|
|
73
84
|
/**
|
|
74
85
|
* Level fill colors for Goals tree nodes — 8 slots, cycled by level index.
|
|
75
86
|
* A single-hue petrol tint ramp (level-0 lightest/root → level-7 deepest);
|
|
@@ -241,6 +252,7 @@ export declare const CSS_VAR: {
|
|
|
241
252
|
readonly layerGoal: "--ts-layer-goal";
|
|
242
253
|
readonly layerChange: "--ts-layer-change";
|
|
243
254
|
readonly layerActivity: "--ts-layer-activity";
|
|
255
|
+
readonly virtualEntity: "--ts-virtual-entity";
|
|
244
256
|
readonly nodeStroke: "--ts-node-stroke";
|
|
245
257
|
readonly edgeStroke: "--ts-edge-stroke";
|
|
246
258
|
readonly textPrimary: "--ts-text-primary";
|
package/dist/theme/tokens.js
CHANGED
|
@@ -38,6 +38,17 @@ export const LAYER_COLORS = {
|
|
|
38
38
|
change: { light: '#c7e5ef', dark: '#1a3842', hc: '#60a5fa' },
|
|
39
39
|
activity: { light: '#b4e0ee', dark: '#1a414e', hc: '#4ade80' },
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* Virtual/synthesized entity fill — used for placeholder nodes that represent
|
|
43
|
+
* absence (e.g. a synthesized Change box when an Action skips straight to its Goal).
|
|
44
|
+
* Pale neutral tint, distinctly muted from real entity colors; border dashed to
|
|
45
|
+
* signal non-reality.
|
|
46
|
+
*/
|
|
47
|
+
export const VIRTUAL_ENTITY = {
|
|
48
|
+
light: '#f3f4f6',
|
|
49
|
+
dark: '#2a3134',
|
|
50
|
+
hc: '#e2e8f0',
|
|
51
|
+
};
|
|
41
52
|
/**
|
|
42
53
|
* Level fill colors for Goals tree nodes — 8 slots, cycled by level index.
|
|
43
54
|
* A single-hue petrol tint ramp (level-0 lightest/root → level-7 deepest);
|
|
@@ -158,6 +169,7 @@ export const CSS_VAR = {
|
|
|
158
169
|
layerGoal: '--ts-layer-goal',
|
|
159
170
|
layerChange: '--ts-layer-change',
|
|
160
171
|
layerActivity: '--ts-layer-activity',
|
|
172
|
+
virtualEntity: '--ts-virtual-entity',
|
|
161
173
|
nodeStroke: '--ts-node-stroke',
|
|
162
174
|
edgeStroke: '--ts-edge-stroke',
|
|
163
175
|
textPrimary: '--ts-text-primary',
|
package/dist/webview/entry.js
CHANGED
|
@@ -124,7 +124,10 @@ function dispatchValidate(kind, doc) {
|
|
|
124
124
|
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
125
125
|
? new Set(meta['__outOfScopeFactorIds'])
|
|
126
126
|
: undefined;
|
|
127
|
-
const
|
|
127
|
+
const progressData = meta['__progressData'] instanceof Map
|
|
128
|
+
? meta['__progressData']
|
|
129
|
+
: undefined;
|
|
130
|
+
const v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
128
131
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
129
132
|
r.errors.push(...v.errors);
|
|
130
133
|
r.warnings.push(...v.warnings);
|
|
@@ -145,7 +148,10 @@ function dispatchValidate(kind, doc) {
|
|
|
145
148
|
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
146
149
|
? new Set(meta['__outOfScopeFactorIds'])
|
|
147
150
|
: undefined;
|
|
148
|
-
const
|
|
151
|
+
const progressData = meta['__progressData'] instanceof Map
|
|
152
|
+
? meta['__progressData']
|
|
153
|
+
: undefined;
|
|
154
|
+
const v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
149
155
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
150
156
|
r.errors.push(...v.errors);
|
|
151
157
|
r.warnings.push(...v.warnings);
|
|
@@ -21,6 +21,7 @@ export interface RenderFgcaBodyOptions {
|
|
|
21
21
|
nodeWidth?: number;
|
|
22
22
|
nodeHeight?: number;
|
|
23
23
|
edgeStyle?: EdgeStyle;
|
|
24
|
+
showCompletionPercent?: boolean;
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
27
|
* Canonical DGCA/DGA body — the column headers, node rects and edge paths that
|
|
@@ -46,6 +47,8 @@ export interface RenderFgcaOptions {
|
|
|
46
47
|
layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
|
|
47
48
|
/** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
|
|
48
49
|
scopeCaption?: boolean;
|
|
50
|
+
/** Show completion percent badges on action nodes. */
|
|
51
|
+
showCompletionPercent?: boolean;
|
|
49
52
|
}
|
|
50
53
|
export declare function renderFgcaSvg(doc: FGCADoc, options?: RenderFgcaOptions): string;
|
|
51
54
|
export {};
|
|
@@ -37,7 +37,9 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
|
|
|
37
37
|
const nodeWidth = bodyOptions.nodeWidth ?? FGCA_NODE_W;
|
|
38
38
|
const nodeHeight = bodyOptions.nodeHeight ?? FGCA_NODE_H;
|
|
39
39
|
const edgeStyle = bodyOptions.edgeStyle;
|
|
40
|
+
const showCompletionPercent = bodyOptions.showCompletionPercent ?? true;
|
|
40
41
|
const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
|
|
42
|
+
const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
41
43
|
const headerSvg = columns
|
|
42
44
|
.map(({ col, x }) => `<text class="text-header" x="${x + nodeWidth / 2}" y="${PAD + HEADER_H / 2}" text-anchor="middle" dominant-baseline="central">${escXml(truncateLine(COL_LABELS[col], headerTruncate))}</text>`)
|
|
43
45
|
.join('\n');
|
|
@@ -47,6 +49,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
|
|
|
47
49
|
const nodeSvg = nodes
|
|
48
50
|
.map((n) => {
|
|
49
51
|
const entityId = n.id.slice(n.id.indexOf('_') + 1);
|
|
52
|
+
const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
|
|
50
53
|
const specs = layoutCenteredEntityText({
|
|
51
54
|
boxX: n.x,
|
|
52
55
|
boxY: n.y,
|
|
@@ -58,16 +61,40 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
|
|
|
58
61
|
idMaxLines: 1,
|
|
59
62
|
});
|
|
60
63
|
const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
|
|
64
|
+
const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
|
|
65
|
+
const typeBadge = n.type && n.col === 'activity'
|
|
66
|
+
? `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 10}" text-anchor="end" dominant-baseline="hanging" font-size="10">${escXml(n.type)}</text>`
|
|
67
|
+
: '';
|
|
68
|
+
const progressBadge = showCompletionPercent && n.col === 'activity'
|
|
69
|
+
? (() => {
|
|
70
|
+
if (n.progress) {
|
|
71
|
+
const computedAt = new Date(n.progress.computedAt);
|
|
72
|
+
const now = new Date();
|
|
73
|
+
const isStale = now.getTime() - computedAt.getTime() > STALENESS_THRESHOLD_MS;
|
|
74
|
+
const opacity = isStale ? ' opacity="0.5"' : '';
|
|
75
|
+
return `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10"${opacity}>${escXml(n.progress.percent + '%')}</text>`;
|
|
76
|
+
}
|
|
77
|
+
if (n.linked) {
|
|
78
|
+
return `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10">–%</text>`;
|
|
79
|
+
}
|
|
80
|
+
return '';
|
|
81
|
+
})()
|
|
82
|
+
: '';
|
|
61
83
|
return [
|
|
62
|
-
`<
|
|
84
|
+
`<g>`,
|
|
85
|
+
`<title>${titleContent}</title>`,
|
|
86
|
+
`<rect class="${nodeClass}" x="${n.x}" y="${n.y}" width="${nodeWidth}" height="${nodeHeight}" rx="8"/>`,
|
|
63
87
|
textSvg,
|
|
64
|
-
|
|
88
|
+
typeBadge,
|
|
89
|
+
progressBadge,
|
|
90
|
+
`</g>`,
|
|
91
|
+
].filter(Boolean).join('\n');
|
|
65
92
|
})
|
|
66
93
|
.join('\n');
|
|
67
94
|
return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
|
|
68
95
|
}
|
|
69
96
|
export function renderFgcaSvg(doc, options = {}) {
|
|
70
|
-
const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, nodeSizePreset = 'normal', layoutOptions, scopeCaption = false, } = options;
|
|
97
|
+
const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, nodeSizePreset = 'normal', layoutOptions, scopeCaption = false, showCompletionPercent = true, } = options;
|
|
71
98
|
const hideChanges = variant === 'dga' || doc.hideChanges === true;
|
|
72
99
|
const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
|
|
73
100
|
const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, {
|
|
@@ -83,6 +110,7 @@ export function renderFgcaSvg(doc, options = {}) {
|
|
|
83
110
|
nodeWidth: nodeSize.width,
|
|
84
111
|
nodeHeight: nodeSize.height,
|
|
85
112
|
edgeStyle,
|
|
113
|
+
showCompletionPercent,
|
|
86
114
|
});
|
|
87
115
|
const titleSvg = title
|
|
88
116
|
? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
|
|
@@ -92,7 +92,12 @@ export function renderGoalsLayoutSvg(layout, options = {}) {
|
|
|
92
92
|
idMaxLines: 1,
|
|
93
93
|
});
|
|
94
94
|
const textSvg = emitCenteredTextSvg(specs, x + n.width / 2, escXml);
|
|
95
|
+
const titleParts = [escXml(labelText), escXml(idText)];
|
|
96
|
+
if (n.data.level !== undefined)
|
|
97
|
+
titleParts.push(`level ${n.data.level}`);
|
|
98
|
+
const titleContent = titleParts.join(' — ');
|
|
95
99
|
return `<g>
|
|
100
|
+
<title>${titleContent}</title>
|
|
96
101
|
<rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="${ENTITY_NODE_RX}"/>
|
|
97
102
|
${textSvg}
|
|
98
103
|
</g>`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transitrix/diagrams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@testing-library/dom": "^10.4.1",
|
|
56
|
-
"@testing-library/react": "^16.3.
|
|
56
|
+
"@testing-library/react": "^16.3.3",
|
|
57
57
|
"@types/react": "^19.2.17",
|
|
58
58
|
"@types/react-dom": "^19.2.5",
|
|
59
59
|
"jsdom": "^29.1.1",
|
|
@@ -89,11 +89,14 @@ export function isDgcaChangesLayerOff(input: unknown): boolean {
|
|
|
89
89
|
* one action or change references an out-of-scope goal, `scopeCaption` is set true.
|
|
90
90
|
* @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
|
|
91
91
|
* in the catalogue, pass their IDs here to suppress FGCA-008 errors.
|
|
92
|
+
* @param progressData Optional map of action ID to progress data (percent + computedAt timestamp).
|
|
93
|
+
* Consumed from `__progressData` metadata field if provided there.
|
|
92
94
|
*/
|
|
93
95
|
export function parseCanonicalFGCA(
|
|
94
96
|
input: unknown,
|
|
95
97
|
outOfScopeGoalIds?: Set<string> | null,
|
|
96
98
|
outOfScopeFactorIds?: Set<string> | null,
|
|
99
|
+
progressData?: Map<string, { percent: number; computedAt: string }> | null,
|
|
97
100
|
): CanonicalFGCAResult {
|
|
98
101
|
const errors: ValidationError[] = [];
|
|
99
102
|
const warnings: ValidationWarning[] = [];
|
|
@@ -322,11 +325,14 @@ export function parseCanonicalFGCA(
|
|
|
322
325
|
const internalActivities: ActivityItem[] = activities.map((el, i) => {
|
|
323
326
|
const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`, outOfScopeGoalIds);
|
|
324
327
|
const typeVal = typeof el!['type'] === 'string' ? el!['type'] : undefined;
|
|
328
|
+
const actId = String(el!['id']);
|
|
329
|
+
const progressVal = progressData?.get(actId);
|
|
325
330
|
return {
|
|
326
|
-
id:
|
|
331
|
+
id: actId,
|
|
327
332
|
name: String(el!['name'] ?? ''),
|
|
328
333
|
goal_id: goalRefs.length > 0 ? goalRefs[0] : null,
|
|
329
334
|
...(typeVal !== undefined ? { type: typeVal } : {}),
|
|
335
|
+
...(progressVal !== undefined ? { progress: progressVal } : {}),
|
|
330
336
|
};
|
|
331
337
|
});
|
|
332
338
|
|
|
@@ -400,6 +406,7 @@ export function parseCanonicalFGA(
|
|
|
400
406
|
input: unknown,
|
|
401
407
|
outOfScopeGoalIds?: Set<string> | null,
|
|
402
408
|
outOfScopeFactorIds?: Set<string> | null,
|
|
409
|
+
progressData?: Map<string, { percent: number; computedAt: string }> | null,
|
|
403
410
|
): CanonicalFGAResult {
|
|
404
411
|
if (!input || typeof input !== 'object') {
|
|
405
412
|
return { valid: false, errors: [{ code: 'FGA-001', message: 'document root is not an object' }], warnings: [] };
|
|
@@ -428,7 +435,7 @@ export function parseCanonicalFGA(
|
|
|
428
435
|
// codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
|
|
429
436
|
// are unreachable here because `changes` is empty.
|
|
430
437
|
const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [] };
|
|
431
|
-
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
438
|
+
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
432
439
|
const remap: Record<string, string> = {
|
|
433
440
|
'FGCA-001': 'FGA-001',
|
|
434
441
|
'FGCA-002': 'FGA-002',
|
|
@@ -33,6 +33,10 @@ export interface FGCAPreviewActivity {
|
|
|
33
33
|
id: number | string;
|
|
34
34
|
name: string;
|
|
35
35
|
goal_id?: number | string | null;
|
|
36
|
+
progress?: {
|
|
37
|
+
percent: number;
|
|
38
|
+
computedAt: string;
|
|
39
|
+
};
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** Structural input the preview layout needs — a subset of the parsed DGCA/DGA doc. */
|
|
@@ -304,6 +308,15 @@ export interface FGCAPreviewNode {
|
|
|
304
308
|
y: number;
|
|
305
309
|
label: string;
|
|
306
310
|
col: FGCAPreviewColumn;
|
|
311
|
+
type?: string;
|
|
312
|
+
progress?: {
|
|
313
|
+
percent: number;
|
|
314
|
+
computedAt: string;
|
|
315
|
+
};
|
|
316
|
+
/** Marks a synthesized node (e.g. virtual Change) vs. a real entity. */
|
|
317
|
+
virtual?: boolean;
|
|
318
|
+
/** For activity nodes: true if the activity is linked to a goal. */
|
|
319
|
+
linked?: boolean;
|
|
307
320
|
}
|
|
308
321
|
export interface FGCAPreviewEdge {
|
|
309
322
|
sx: number;
|
|
@@ -354,11 +367,26 @@ export function layoutFGCAPreview(
|
|
|
354
367
|
? ['driver', 'goal', 'activity']
|
|
355
368
|
: ['driver', 'goal', 'change', 'activity'];
|
|
356
369
|
const changes = doc.changes ?? [];
|
|
357
|
-
const
|
|
370
|
+
const coveredActivities = new Set(changes.flatMap(c => c.activity_ids.map(String)));
|
|
371
|
+
|
|
372
|
+
// Synthesize virtual Change nodes for activities without a real Change.
|
|
373
|
+
// Each virtual node is 1:1 with one activity (not shared across activities).
|
|
374
|
+
const virtualChanges: Array<{ id: string; activityId: string; goalId: string }> = [];
|
|
375
|
+
for (const a of doc.activities) {
|
|
376
|
+
if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
|
|
377
|
+
const virtualId = `vchange_activity_${a.id}`;
|
|
378
|
+
virtualChanges.push({ id: virtualId, activityId: String(a.id), goalId: String(a.goal_id) });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const colItems: Record<FGCAPreviewColumn, Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }>> = {
|
|
358
383
|
driver: doc.factors.map(f => ({ id: `driver_${f.id}`, label: f.name })),
|
|
359
384
|
goal: doc.goals.map(g => ({ id: `goal_${g.id}`, label: g.name })),
|
|
360
|
-
change:
|
|
361
|
-
|
|
385
|
+
change: [
|
|
386
|
+
...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
|
|
387
|
+
...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
|
|
388
|
+
],
|
|
389
|
+
activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: a.goal_id != null })),
|
|
362
390
|
};
|
|
363
391
|
|
|
364
392
|
// Build predecessor map: for each node, which node IDs in the previous column
|
|
@@ -371,12 +399,17 @@ export function layoutFGCAPreview(
|
|
|
371
399
|
for (const c of changes) {
|
|
372
400
|
predecessors.set(`change_${c.id}`, [`goal_${c.goal_id}`]);
|
|
373
401
|
}
|
|
374
|
-
const
|
|
402
|
+
for (const v of virtualChanges) {
|
|
403
|
+
predecessors.set(v.id, [`goal_${v.goalId}`]);
|
|
404
|
+
}
|
|
375
405
|
for (const a of doc.activities) {
|
|
376
406
|
const preds = changes
|
|
377
407
|
.filter(c => c.activity_ids.map(String).includes(String(a.id)))
|
|
378
408
|
.map(c => `change_${c.id}`);
|
|
379
|
-
|
|
409
|
+
const virtualChange = virtualChanges.find(v => v.activityId === String(a.id));
|
|
410
|
+
if (virtualChange) {
|
|
411
|
+
preds.push(virtualChange.id);
|
|
412
|
+
}
|
|
380
413
|
predecessors.set(`activity_${a.id}`, preds);
|
|
381
414
|
}
|
|
382
415
|
} else {
|
|
@@ -398,9 +431,9 @@ export function layoutFGCAPreview(
|
|
|
398
431
|
// Nodes with no predecessors sort last (Infinity barycenter) so they don't
|
|
399
432
|
// displace connected nodes.
|
|
400
433
|
function barycentricSort(
|
|
401
|
-
items: Array<{ id: string; label: string }>,
|
|
434
|
+
items: Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }>,
|
|
402
435
|
yCenters: Map<string, number>,
|
|
403
|
-
): Array<{ id: string; label: string }> {
|
|
436
|
+
): Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }> {
|
|
404
437
|
return [...items].sort((a, b) => {
|
|
405
438
|
const pA = (predecessors.get(a.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
|
|
406
439
|
const pB = (predecessors.get(b.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
|
|
@@ -422,7 +455,17 @@ export function layoutFGCAPreview(
|
|
|
422
455
|
const items = ci === 0 ? colItems[col] : barycentricSort(colItems[col], yCenters);
|
|
423
456
|
let y = FGCA_PAD + FGCA_HEADER_H + rowGap;
|
|
424
457
|
for (const item of items) {
|
|
425
|
-
const node: FGCAPreviewNode = {
|
|
458
|
+
const node: FGCAPreviewNode = {
|
|
459
|
+
id: item.id,
|
|
460
|
+
x,
|
|
461
|
+
y,
|
|
462
|
+
label: item.label,
|
|
463
|
+
col,
|
|
464
|
+
type: item.type,
|
|
465
|
+
progress: item.progress,
|
|
466
|
+
virtual: item.virtual,
|
|
467
|
+
linked: item.linked,
|
|
468
|
+
};
|
|
426
469
|
nodes.push(node);
|
|
427
470
|
nodeMap.set(item.id, node);
|
|
428
471
|
yCenters.set(item.id, y + nodeHeight / 2);
|
|
@@ -457,12 +500,8 @@ export function layoutFGCAPreview(
|
|
|
457
500
|
} else {
|
|
458
501
|
for (const c of changes) addEdge(`goal_${c.goal_id}`, `change_${c.id}`);
|
|
459
502
|
for (const c of changes) for (const aid of c.activity_ids) addEdge(`change_${c.id}`, `activity_${aid}`);
|
|
460
|
-
const
|
|
461
|
-
for (const
|
|
462
|
-
if (a.goal_id != null && !coveredActivities.has(a.id)) {
|
|
463
|
-
addEdge(`goal_${a.goal_id}`, `activity_${a.id}`);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
503
|
+
for (const v of virtualChanges) addEdge(`goal_${v.goalId}`, v.id);
|
|
504
|
+
for (const v of virtualChanges) addEdge(v.id, `activity_${v.activityId}`);
|
|
466
505
|
}
|
|
467
506
|
|
|
468
507
|
const maxNodeBottom = nodes.reduce(
|
package/src/fgca/resolver.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface FGCAViewConfig {
|
|
|
26
26
|
export interface FGCACanonSources {
|
|
27
27
|
elements: unknown[];
|
|
28
28
|
relations: unknown[];
|
|
29
|
+
progressData?: Map<string, { percent: number; computedAt: string }>;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function isObject(v: unknown): v is Record<string, unknown> {
|
|
@@ -192,5 +193,6 @@ export function resolveFGCA(
|
|
|
192
193
|
view_config: viewDoc['view_config'],
|
|
193
194
|
__outOfScopeGoalIds: outOfScopeGoalIds,
|
|
194
195
|
__outOfScopeFactorIds: outOfScopeFactorIds,
|
|
196
|
+
__progressData: sources.progressData,
|
|
195
197
|
};
|
|
196
198
|
}
|
package/src/fgca/types.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface ActivityItem {
|
|
|
25
25
|
activity_type_id?: number;
|
|
26
26
|
/** Canonical values: initiative | programme | project | work_package. Unknown values pass through without error. */
|
|
27
27
|
type?: string;
|
|
28
|
+
progress?: {
|
|
29
|
+
percent: number;
|
|
30
|
+
computedAt: string;
|
|
31
|
+
};
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export interface ActivityTypeItem {
|
package/src/theme/themes.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
MATURITY_COLORS,
|
|
5
5
|
TREE_LEVEL_COLORS,
|
|
6
6
|
TREE_MATURITY_COLORS,
|
|
7
|
+
VIRTUAL_ENTITY,
|
|
7
8
|
STRUCTURAL,
|
|
8
9
|
BRAND_EMPHASIS,
|
|
9
10
|
TYPOGRAPHY,
|
|
@@ -97,6 +98,7 @@ function diagramVars(variant: 'light' | 'dark' | 'hc'): string {
|
|
|
97
98
|
`${CSS_VAR.layerGoal}:${lc.goal[variant]}`,
|
|
98
99
|
`${CSS_VAR.layerChange}:${lc.change[variant]}`,
|
|
99
100
|
`${CSS_VAR.layerActivity}:${lc.activity[variant]}`,
|
|
101
|
+
`${CSS_VAR.virtualEntity}:${VIRTUAL_ENTITY[variant]}`,
|
|
100
102
|
`${CSS_VAR.nodeStroke}:${sc.nodeStroke[variant]}`,
|
|
101
103
|
`${CSS_VAR.edgeStroke}:${sc.edgeStroke[variant]}`,
|
|
102
104
|
`${CSS_VAR.textPrimary}:${sc.textPrimary[variant]}`,
|
|
@@ -155,6 +157,7 @@ function diagramClassCss(): string {
|
|
|
155
157
|
.layer-goal{fill:var(${cv.layerGoal});}
|
|
156
158
|
.layer-change{fill:var(${cv.layerChange});}
|
|
157
159
|
.layer-activity{fill:var(${cv.layerActivity});}
|
|
160
|
+
.layer-virtual{fill:var(${cv.virtualEntity});stroke-dasharray:4,3;}
|
|
158
161
|
${levelRules}
|
|
159
162
|
.diagram-edge{stroke:var(${cv.edgeStroke});stroke-width:1.5;fill:none;}
|
|
160
163
|
.arrow-fill{fill:var(${cv.edgeStroke});}
|
package/src/theme/tokens.ts
CHANGED
|
@@ -42,6 +42,18 @@ export const LAYER_COLORS = {
|
|
|
42
42
|
activity: { light: '#b4e0ee', dark: '#1a414e', hc: '#4ade80' },
|
|
43
43
|
} as const;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Virtual/synthesized entity fill — used for placeholder nodes that represent
|
|
47
|
+
* absence (e.g. a synthesized Change box when an Action skips straight to its Goal).
|
|
48
|
+
* Pale neutral tint, distinctly muted from real entity colors; border dashed to
|
|
49
|
+
* signal non-reality.
|
|
50
|
+
*/
|
|
51
|
+
export const VIRTUAL_ENTITY = {
|
|
52
|
+
light: '#f3f4f6',
|
|
53
|
+
dark: '#2a3134',
|
|
54
|
+
hc: '#e2e8f0',
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
45
57
|
/**
|
|
46
58
|
* Level fill colors for Goals tree nodes — 8 slots, cycled by level index.
|
|
47
59
|
* A single-hue petrol tint ramp (level-0 lightest/root → level-7 deepest);
|
|
@@ -188,6 +200,7 @@ export const CSS_VAR = {
|
|
|
188
200
|
layerGoal: '--ts-layer-goal',
|
|
189
201
|
layerChange: '--ts-layer-change',
|
|
190
202
|
layerActivity: '--ts-layer-activity',
|
|
203
|
+
virtualEntity: '--ts-virtual-entity',
|
|
191
204
|
nodeStroke: '--ts-node-stroke',
|
|
192
205
|
edgeStroke: '--ts-edge-stroke',
|
|
193
206
|
textPrimary: '--ts-text-primary',
|
package/src/webview/entry.ts
CHANGED
|
@@ -198,7 +198,10 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
198
198
|
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
199
199
|
? new Set(meta['__outOfScopeFactorIds'] as string[])
|
|
200
200
|
: undefined;
|
|
201
|
-
const
|
|
201
|
+
const progressData = meta['__progressData'] instanceof Map
|
|
202
|
+
? (meta['__progressData'] as Map<string, { percent: number; computedAt: string }>)
|
|
203
|
+
: undefined;
|
|
204
|
+
const v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
202
205
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
203
206
|
r.errors.push(...v.errors);
|
|
204
207
|
r.warnings.push(...v.warnings);
|
|
@@ -219,7 +222,10 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
219
222
|
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
220
223
|
? new Set(meta['__outOfScopeFactorIds'] as string[])
|
|
221
224
|
: undefined;
|
|
222
|
-
const
|
|
225
|
+
const progressData = meta['__progressData'] instanceof Map
|
|
226
|
+
? (meta['__progressData'] as Map<string, { percent: number; computedAt: string }>)
|
|
227
|
+
: undefined;
|
|
228
|
+
const v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
223
229
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
224
230
|
r.errors.push(...v.errors);
|
|
225
231
|
r.warnings.push(...v.warnings);
|
|
@@ -40,6 +40,7 @@ export interface RenderFgcaBodyOptions {
|
|
|
40
40
|
nodeWidth?: number;
|
|
41
41
|
nodeHeight?: number;
|
|
42
42
|
edgeStyle?: EdgeStyle;
|
|
43
|
+
showCompletionPercent?: boolean;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -62,7 +63,9 @@ export function renderFgcaBody(
|
|
|
62
63
|
const nodeWidth = bodyOptions.nodeWidth ?? FGCA_NODE_W;
|
|
63
64
|
const nodeHeight = bodyOptions.nodeHeight ?? FGCA_NODE_H;
|
|
64
65
|
const edgeStyle = bodyOptions.edgeStyle;
|
|
66
|
+
const showCompletionPercent = bodyOptions.showCompletionPercent ?? true;
|
|
65
67
|
const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
|
|
68
|
+
const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
66
69
|
|
|
67
70
|
const headerSvg = columns
|
|
68
71
|
.map(({ col, x }) =>
|
|
@@ -80,6 +83,7 @@ export function renderFgcaBody(
|
|
|
80
83
|
const nodeSvg = nodes
|
|
81
84
|
.map((n) => {
|
|
82
85
|
const entityId = n.id.slice(n.id.indexOf('_') + 1);
|
|
86
|
+
const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
|
|
83
87
|
const specs = layoutCenteredEntityText({
|
|
84
88
|
boxX: n.x,
|
|
85
89
|
boxY: n.y,
|
|
@@ -91,10 +95,34 @@ export function renderFgcaBody(
|
|
|
91
95
|
idMaxLines: 1,
|
|
92
96
|
});
|
|
93
97
|
const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
|
|
98
|
+
const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
|
|
99
|
+
const typeBadge = n.type && n.col === 'activity'
|
|
100
|
+
? `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 10}" text-anchor="end" dominant-baseline="hanging" font-size="10">${escXml(n.type)}</text>`
|
|
101
|
+
: '';
|
|
102
|
+
const progressBadge = showCompletionPercent && n.col === 'activity'
|
|
103
|
+
? (() => {
|
|
104
|
+
if (n.progress) {
|
|
105
|
+
const computedAt = new Date(n.progress.computedAt);
|
|
106
|
+
const now = new Date();
|
|
107
|
+
const isStale = now.getTime() - computedAt.getTime() > STALENESS_THRESHOLD_MS;
|
|
108
|
+
const opacity = isStale ? ' opacity="0.5"' : '';
|
|
109
|
+
return `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10"${opacity}>${escXml(n.progress.percent + '%')}</text>`;
|
|
110
|
+
}
|
|
111
|
+
if (n.linked) {
|
|
112
|
+
return `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10">–%</text>`;
|
|
113
|
+
}
|
|
114
|
+
return '';
|
|
115
|
+
})()
|
|
116
|
+
: '';
|
|
94
117
|
return [
|
|
95
|
-
`<
|
|
118
|
+
`<g>`,
|
|
119
|
+
`<title>${titleContent}</title>`,
|
|
120
|
+
`<rect class="${nodeClass}" x="${n.x}" y="${n.y}" width="${nodeWidth}" height="${nodeHeight}" rx="8"/>`,
|
|
96
121
|
textSvg,
|
|
97
|
-
|
|
122
|
+
typeBadge,
|
|
123
|
+
progressBadge,
|
|
124
|
+
`</g>`,
|
|
125
|
+
].filter(Boolean).join('\n');
|
|
98
126
|
})
|
|
99
127
|
.join('\n');
|
|
100
128
|
|
|
@@ -115,6 +143,8 @@ export interface RenderFgcaOptions {
|
|
|
115
143
|
layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
|
|
116
144
|
/** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
|
|
117
145
|
scopeCaption?: boolean;
|
|
146
|
+
/** Show completion percent badges on action nodes. */
|
|
147
|
+
showCompletionPercent?: boolean;
|
|
118
148
|
}
|
|
119
149
|
|
|
120
150
|
export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): string {
|
|
@@ -127,6 +157,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
127
157
|
nodeSizePreset = 'normal',
|
|
128
158
|
layoutOptions,
|
|
129
159
|
scopeCaption = false,
|
|
160
|
+
showCompletionPercent = true,
|
|
130
161
|
} = options;
|
|
131
162
|
const hideChanges = variant === 'dga' || doc.hideChanges === true;
|
|
132
163
|
const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
|
|
@@ -146,6 +177,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
146
177
|
nodeWidth: nodeSize.width,
|
|
147
178
|
nodeHeight: nodeSize.height,
|
|
148
179
|
edgeStyle,
|
|
180
|
+
showCompletionPercent,
|
|
149
181
|
});
|
|
150
182
|
|
|
151
183
|
const titleSvg = title
|
|
@@ -124,7 +124,11 @@ export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoal
|
|
|
124
124
|
idMaxLines: 1,
|
|
125
125
|
});
|
|
126
126
|
const textSvg = emitCenteredTextSvg(specs, x + n.width / 2, escXml);
|
|
127
|
+
const titleParts = [escXml(labelText), escXml(idText)];
|
|
128
|
+
if (n.data.level !== undefined) titleParts.push(`level ${n.data.level}`);
|
|
129
|
+
const titleContent = titleParts.join(' — ');
|
|
127
130
|
return `<g>
|
|
131
|
+
<title>${titleContent}</title>
|
|
128
132
|
<rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="${ENTITY_NODE_RX}"/>
|
|
129
133
|
${textSvg}
|
|
130
134
|
</g>`;
|