@transitrix/diagrams 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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): CanonicalFGCAResult;
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): CanonicalFGAResult;
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: String(el['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,13 @@ 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;
86
97
  }
87
98
  export interface FGCAPreviewEdge {
88
99
  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: changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
239
- activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name })),
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 })),
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 coveredActs = new Set(changes.flatMap(c => c.activity_ids.map(String)));
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
- if (a.goal_id != null && !coveredActs.has(String(a.id)))
257
- preds.push(`goal_${a.goal_id}`);
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,16 @@ 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 = { id: item.id, x, y, label: item.label, col };
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
+ };
299
325
  nodes.push(node);
300
326
  nodeMap.set(item.id, node);
301
327
  yCenters.set(item.id, y + nodeHeight / 2);
@@ -334,12 +360,10 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
334
360
  for (const c of changes)
335
361
  for (const aid of c.activity_ids)
336
362
  addEdge(`change_${c.id}`, `activity_${aid}`);
337
- const coveredActivities = new Set(changes.flatMap(c => c.activity_ids));
338
- for (const a of doc.activities) {
339
- if (a.goal_id != null && !coveredActivities.has(a.id)) {
340
- addEdge(`goal_${a.goal_id}`, `activity_${a.id}`);
341
- }
342
- }
363
+ for (const v of virtualChanges)
364
+ addEdge(`goal_${v.goalId}`, v.id);
365
+ for (const v of virtualChanges)
366
+ addEdge(v.id, `activity_${v.activityId}`);
343
367
  }
344
368
  const maxNodeBottom = nodes.reduce((m, n) => Math.max(m, n.y + nodeHeight), FGCA_PAD + FGCA_HEADER_H + rowGap + nodeHeight);
345
369
  const width = FGCA_PAD * 2 + cols.length * colStride - colGap;
@@ -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;
@@ -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});}
@@ -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";
@@ -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',
@@ -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 v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
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 v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
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);
@@ -38,6 +38,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
38
38
  const nodeHeight = bodyOptions.nodeHeight ?? FGCA_NODE_H;
39
39
  const edgeStyle = bodyOptions.edgeStyle;
40
40
  const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
41
+ const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
41
42
  const headerSvg = columns
42
43
  .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
44
  .join('\n');
@@ -47,6 +48,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
47
48
  const nodeSvg = nodes
48
49
  .map((n) => {
49
50
  const entityId = n.id.slice(n.id.indexOf('_') + 1);
51
+ const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
50
52
  const specs = layoutCenteredEntityText({
51
53
  boxX: n.x,
52
54
  boxY: n.y,
@@ -58,10 +60,29 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
58
60
  idMaxLines: 1,
59
61
  });
60
62
  const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
63
+ const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
64
+ const typeBadge = n.type && n.col === 'activity'
65
+ ? `<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>`
66
+ : '';
67
+ const progressBadge = n.progress && n.col === 'activity'
68
+ ? (() => {
69
+ const computedAt = new Date(n.progress.computedAt);
70
+ const now = new Date();
71
+ const isStale = now.getTime() - computedAt.getTime() > STALENESS_THRESHOLD_MS;
72
+ const className = isStale ? 'text-id' : 'text-id';
73
+ const opacity = isStale ? ' opacity="0.5"' : '';
74
+ return `<text class="${className}" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10"${opacity}>${escXml(n.progress.percent + '%')}</text>`;
75
+ })()
76
+ : '';
61
77
  return [
62
- `<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${nodeWidth}" height="${nodeHeight}" rx="8"/>`,
78
+ `<g>`,
79
+ `<title>${titleContent}</title>`,
80
+ `<rect class="${nodeClass}" x="${n.x}" y="${n.y}" width="${nodeWidth}" height="${nodeHeight}" rx="8"/>`,
63
81
  textSvg,
64
- ].join('\n');
82
+ typeBadge,
83
+ progressBadge,
84
+ `</g>`,
85
+ ].filter(Boolean).join('\n');
65
86
  })
66
87
  .join('\n');
67
88
  return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
@@ -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.12.2",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -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: String(el!['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,13 @@ 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;
307
318
  }
308
319
  export interface FGCAPreviewEdge {
309
320
  sx: number;
@@ -354,11 +365,26 @@ export function layoutFGCAPreview(
354
365
  ? ['driver', 'goal', 'activity']
355
366
  : ['driver', 'goal', 'change', 'activity'];
356
367
  const changes = doc.changes ?? [];
357
- const colItems: Record<FGCAPreviewColumn, Array<{ id: string; label: string }>> = {
368
+ const coveredActivities = new Set(changes.flatMap(c => c.activity_ids.map(String)));
369
+
370
+ // Synthesize virtual Change nodes for activities without a real Change.
371
+ // Each virtual node is 1:1 with one activity (not shared across activities).
372
+ const virtualChanges: Array<{ id: string; activityId: string; goalId: string }> = [];
373
+ for (const a of doc.activities) {
374
+ if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
375
+ const virtualId = `vchange_activity_${a.id}`;
376
+ virtualChanges.push({ id: virtualId, activityId: String(a.id), goalId: String(a.goal_id) });
377
+ }
378
+ }
379
+
380
+ const colItems: Record<FGCAPreviewColumn, Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }>> = {
358
381
  driver: doc.factors.map(f => ({ id: `driver_${f.id}`, label: f.name })),
359
382
  goal: doc.goals.map(g => ({ id: `goal_${g.id}`, label: g.name })),
360
- change: changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
361
- activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name })),
383
+ change: [
384
+ ...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
385
+ ...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
386
+ ],
387
+ activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress })),
362
388
  };
363
389
 
364
390
  // Build predecessor map: for each node, which node IDs in the previous column
@@ -371,12 +397,17 @@ export function layoutFGCAPreview(
371
397
  for (const c of changes) {
372
398
  predecessors.set(`change_${c.id}`, [`goal_${c.goal_id}`]);
373
399
  }
374
- const coveredActs = new Set(changes.flatMap(c => c.activity_ids.map(String)));
400
+ for (const v of virtualChanges) {
401
+ predecessors.set(v.id, [`goal_${v.goalId}`]);
402
+ }
375
403
  for (const a of doc.activities) {
376
404
  const preds = changes
377
405
  .filter(c => c.activity_ids.map(String).includes(String(a.id)))
378
406
  .map(c => `change_${c.id}`);
379
- if (a.goal_id != null && !coveredActs.has(String(a.id))) preds.push(`goal_${a.goal_id}`);
407
+ const virtualChange = virtualChanges.find(v => v.activityId === String(a.id));
408
+ if (virtualChange) {
409
+ preds.push(virtualChange.id);
410
+ }
380
411
  predecessors.set(`activity_${a.id}`, preds);
381
412
  }
382
413
  } else {
@@ -398,9 +429,9 @@ export function layoutFGCAPreview(
398
429
  // Nodes with no predecessors sort last (Infinity barycenter) so they don't
399
430
  // displace connected nodes.
400
431
  function barycentricSort(
401
- items: Array<{ id: string; label: string }>,
432
+ items: Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }>,
402
433
  yCenters: Map<string, number>,
403
- ): Array<{ id: string; label: string }> {
434
+ ): Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }> {
404
435
  return [...items].sort((a, b) => {
405
436
  const pA = (predecessors.get(a.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
406
437
  const pB = (predecessors.get(b.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
@@ -422,7 +453,16 @@ export function layoutFGCAPreview(
422
453
  const items = ci === 0 ? colItems[col] : barycentricSort(colItems[col], yCenters);
423
454
  let y = FGCA_PAD + FGCA_HEADER_H + rowGap;
424
455
  for (const item of items) {
425
- const node: FGCAPreviewNode = { id: item.id, x, y, label: item.label, col };
456
+ const node: FGCAPreviewNode = {
457
+ id: item.id,
458
+ x,
459
+ y,
460
+ label: item.label,
461
+ col,
462
+ type: item.type,
463
+ progress: item.progress,
464
+ virtual: item.virtual,
465
+ };
426
466
  nodes.push(node);
427
467
  nodeMap.set(item.id, node);
428
468
  yCenters.set(item.id, y + nodeHeight / 2);
@@ -457,12 +497,8 @@ export function layoutFGCAPreview(
457
497
  } else {
458
498
  for (const c of changes) addEdge(`goal_${c.goal_id}`, `change_${c.id}`);
459
499
  for (const c of changes) for (const aid of c.activity_ids) addEdge(`change_${c.id}`, `activity_${aid}`);
460
- const coveredActivities = new Set(changes.flatMap(c => c.activity_ids));
461
- for (const a of doc.activities) {
462
- if (a.goal_id != null && !coveredActivities.has(a.id)) {
463
- addEdge(`goal_${a.goal_id}`, `activity_${a.id}`);
464
- }
465
- }
500
+ for (const v of virtualChanges) addEdge(`goal_${v.goalId}`, v.id);
501
+ for (const v of virtualChanges) addEdge(v.id, `activity_${v.activityId}`);
466
502
  }
467
503
 
468
504
  const maxNodeBottom = nodes.reduce(
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 {
@@ -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});}
@@ -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',
@@ -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 v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
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 v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
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);
@@ -63,6 +63,7 @@ export function renderFgcaBody(
63
63
  const nodeHeight = bodyOptions.nodeHeight ?? FGCA_NODE_H;
64
64
  const edgeStyle = bodyOptions.edgeStyle;
65
65
  const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
66
+ const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
66
67
 
67
68
  const headerSvg = columns
68
69
  .map(({ col, x }) =>
@@ -80,6 +81,7 @@ export function renderFgcaBody(
80
81
  const nodeSvg = nodes
81
82
  .map((n) => {
82
83
  const entityId = n.id.slice(n.id.indexOf('_') + 1);
84
+ const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
83
85
  const specs = layoutCenteredEntityText({
84
86
  boxX: n.x,
85
87
  boxY: n.y,
@@ -91,10 +93,29 @@ export function renderFgcaBody(
91
93
  idMaxLines: 1,
92
94
  });
93
95
  const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
96
+ const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
97
+ const typeBadge = n.type && n.col === 'activity'
98
+ ? `<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>`
99
+ : '';
100
+ const progressBadge = n.progress && n.col === 'activity'
101
+ ? (() => {
102
+ const computedAt = new Date(n.progress.computedAt);
103
+ const now = new Date();
104
+ const isStale = now.getTime() - computedAt.getTime() > STALENESS_THRESHOLD_MS;
105
+ const className = isStale ? 'text-id' : 'text-id';
106
+ const opacity = isStale ? ' opacity="0.5"' : '';
107
+ return `<text class="${className}" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10"${opacity}>${escXml(n.progress.percent + '%')}</text>`;
108
+ })()
109
+ : '';
94
110
  return [
95
- `<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${nodeWidth}" height="${nodeHeight}" rx="8"/>`,
111
+ `<g>`,
112
+ `<title>${titleContent}</title>`,
113
+ `<rect class="${nodeClass}" x="${n.x}" y="${n.y}" width="${nodeWidth}" height="${nodeHeight}" rx="8"/>`,
96
114
  textSvg,
97
- ].join('\n');
115
+ typeBadge,
116
+ progressBadge,
117
+ `</g>`,
118
+ ].filter(Boolean).join('\n');
98
119
  })
99
120
  .join('\n');
100
121
 
@@ -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>`;