@transitrix/diagrams 1.13.0 → 1.13.2

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.
@@ -94,6 +94,8 @@ export interface FGCAPreviewNode {
94
94
  };
95
95
  /** Marks a synthesized node (e.g. virtual Change) vs. a real entity. */
96
96
  virtual?: boolean;
97
+ /** For activity nodes: true if the activity is linked to a goal. */
98
+ linked?: boolean;
97
99
  }
98
100
  export interface FGCAPreviewEdge {
99
101
  sx: number;
@@ -235,6 +235,7 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
235
235
  const coveredActivities = new Set(changes.flatMap(c => c.activity_ids.map(String)));
236
236
  // Synthesize virtual Change nodes for activities without a real Change.
237
237
  // Each virtual node is 1:1 with one activity (not shared across activities).
238
+ // The layout key embeds the activity id for uniqueness; it is not a display id.
238
239
  const virtualChanges = [];
239
240
  for (const a of doc.activities) {
240
241
  if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
@@ -249,7 +250,7 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
249
250
  ...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
250
251
  ...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
251
252
  ],
252
- activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress })),
253
+ activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: a.goal_id != null })),
253
254
  };
254
255
  // Build predecessor map: for each node, which node IDs in the previous column
255
256
  // connect to it from the left. Used for barycenter crossing minimization.
@@ -321,6 +322,7 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
321
322
  type: item.type,
322
323
  progress: item.progress,
323
324
  virtual: item.virtual,
325
+ linked: item.linked,
324
326
  };
325
327
  nodes.push(node);
326
328
  nodeMap.set(item.id, node);
@@ -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
@@ -148,5 +148,6 @@ export function resolveFGCA(viewDoc, sources) {
148
148
  view_config: viewDoc['view_config'],
149
149
  __outOfScopeGoalIds: outOfScopeGoalIds,
150
150
  __outOfScopeFactorIds: outOfScopeFactorIds,
151
+ __progressData: sources.progressData,
151
152
  };
152
153
  }
@@ -64,7 +64,8 @@ export interface LayoutCenteredEntityOptions {
64
64
  boxHeight: number;
65
65
  name: string;
66
66
  type?: string;
67
- id: string;
67
+ /** Canonical entity id. Empty or omitted skips the id row (placeholder nodes). */
68
+ id?: string;
68
69
  nameMaxLines?: number;
69
70
  idMaxLines?: number;
70
71
  marginX?: number;
@@ -159,7 +159,15 @@ export function truncateId(id, maxChars) {
159
159
  * to avoid overlap.
160
160
  */
161
161
  const HALF_EXTENT_PRIMARY = 6; // text-primary, 12px font
162
+ const HALF_EXTENT_SECONDARY = 6; // text-secondary, same size as name
162
163
  const HALF_EXTENT_ID = 5; // text-id, 10px font
164
+ function glyphHalfExtent(cls) {
165
+ if (cls === 'text-id')
166
+ return HALF_EXTENT_ID;
167
+ if (cls === 'text-secondary')
168
+ return HALF_EXTENT_SECONDARY;
169
+ return HALF_EXTENT_PRIMARY;
170
+ }
163
171
  /** Place `groups`' lines starting at `startY`, advancing by each line's own height and gap. */
164
172
  function placeLines(groups, startY) {
165
173
  const out = [];
@@ -201,15 +209,18 @@ export function layoutCenteredEntityText(opts) {
201
209
  const maxCharsType = maxCharsForInnerWidth(innerW, CHAR_W_SECONDARY);
202
210
  const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
203
211
  const typeLine = opts.type?.trim() ? truncateLine(opts.type, maxCharsType) : undefined;
212
+ const idText = opts.id?.trim() ?? '';
204
213
  const idMaxLines = opts.idMaxLines ?? 1;
205
- const idLines = idMaxLines <= 1 ? [truncateId(opts.id, maxCharsId)] : wrapId(opts.id, maxCharsId, idMaxLines);
214
+ const idLines = idText
215
+ ? (idMaxLines <= 1 ? [truncateId(idText, maxCharsId)] : wrapId(idText, maxCharsId, idMaxLines))
216
+ : [];
206
217
  function buildGroups(nameLines) {
207
218
  const groups = [
208
219
  {
209
220
  cls: 'text-primary',
210
221
  lines: nameLines,
211
222
  lineHeight: LINE_H_PRIMARY,
212
- gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
223
+ gapAfter: typeLine ? ROW_GROUP_GAP : idLines.length > 0 ? NAME_ID_GAP : 0,
213
224
  },
214
225
  ];
215
226
  if (typeLine) {
@@ -217,28 +228,30 @@ export function layoutCenteredEntityText(opts) {
217
228
  cls: 'text-secondary',
218
229
  lines: [typeLine],
219
230
  lineHeight: LINE_H_SECONDARY,
220
- gapAfter: NAME_ID_GAP,
231
+ gapAfter: idLines.length > 0 ? NAME_ID_GAP : 0,
232
+ });
233
+ }
234
+ if (idLines.length > 0) {
235
+ groups.push({
236
+ cls: 'text-id',
237
+ lines: idLines,
238
+ lineHeight: LINE_H_ID,
239
+ gapAfter: 0,
221
240
  });
222
241
  }
223
- groups.push({
224
- cls: 'text-id',
225
- lines: idLines,
226
- lineHeight: LINE_H_ID,
227
- gapAfter: 0,
228
- });
229
242
  return groups;
230
243
  }
231
- // groups[0] is always the name (text-primary) and the last group is always
232
- // the id (text-id), so the edge-padding half-extents below are fixed.
244
+ // groups[0] is always the name (text-primary). The last group is the id when
245
+ // one is present, otherwise type or name; edge padding uses that class.
233
246
  const requestedNameMaxLines = Math.max(1, opts.nameMaxLines ?? 2);
234
247
  const availableHeight = Math.max(0, opts.boxHeight - marginY * 2);
235
248
  let nameLines = wrapWords(opts.name, maxCharsName, requestedNameMaxLines);
236
249
  let groups = buildGroups(nameLines);
237
- let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
250
+ let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + glyphHalfExtent(groups[groups.length - 1].cls);
238
251
  for (let cap = requestedNameMaxLines - 1; cap >= 1 && contentSpan > availableHeight; cap--) {
239
252
  nameLines = wrapWords(opts.name, maxCharsName, cap);
240
253
  groups = buildGroups(nameLines);
241
- contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
254
+ contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + glyphHalfExtent(groups[groups.length - 1].cls);
242
255
  }
243
256
  const margin = Math.max(0, (opts.boxHeight - contentSpan) / 2);
244
257
  const firstY = Math.round(opts.boxY + margin + HALF_EXTENT_PRIMARY);
@@ -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,6 +37,7 @@ 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));
41
42
  const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
42
43
  const headerSvg = columns
@@ -47,7 +48,9 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
47
48
  .join('\n');
48
49
  const nodeSvg = nodes
49
50
  .map((n) => {
50
- const entityId = n.id.slice(n.id.indexOf('_') + 1);
51
+ // Virtual Change nodes use a layout key such as `vchange_activity_<id>`;
52
+ // slicing at the first `_` would print `activity_<id>` on a Change.
53
+ const entityId = n.virtual ? '' : n.id.slice(n.id.indexOf('_') + 1);
51
54
  const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
52
55
  const specs = layoutCenteredEntityText({
53
56
  boxX: n.x,
@@ -60,18 +63,25 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
60
63
  idMaxLines: 1,
61
64
  });
62
65
  const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
63
- const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
66
+ const titleContent = n.virtual
67
+ ? escXml(n.label)
68
+ : `${escXml(n.label)} (${escXml(entityId)})`;
64
69
  const typeBadge = n.type && n.col === 'activity'
65
70
  ? `<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
71
  : '';
67
- const progressBadge = n.progress && n.col === 'activity'
72
+ const progressBadge = showCompletionPercent && n.col === 'activity'
68
73
  ? (() => {
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>`;
74
+ if (n.progress) {
75
+ const computedAt = new Date(n.progress.computedAt);
76
+ const now = new Date();
77
+ const isStale = now.getTime() - computedAt.getTime() > STALENESS_THRESHOLD_MS;
78
+ const opacity = isStale ? ' opacity="0.5"' : '';
79
+ 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>`;
80
+ }
81
+ if (n.linked) {
82
+ return `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10">–%</text>`;
83
+ }
84
+ return '';
75
85
  })()
76
86
  : '';
77
87
  return [
@@ -88,7 +98,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
88
98
  return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
89
99
  }
90
100
  export function renderFgcaSvg(doc, options = {}) {
91
- const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, nodeSizePreset = 'normal', layoutOptions, scopeCaption = false, } = options;
101
+ const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, nodeSizePreset = 'normal', layoutOptions, scopeCaption = false, showCompletionPercent = true, } = options;
92
102
  const hideChanges = variant === 'dga' || doc.hideChanges === true;
93
103
  const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
94
104
  const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, {
@@ -104,6 +114,7 @@ export function renderFgcaSvg(doc, options = {}) {
104
114
  nodeWidth: nodeSize.width,
105
115
  nodeHeight: nodeSize.height,
106
116
  edgeStyle,
117
+ showCompletionPercent,
107
118
  });
108
119
  const titleSvg = title
109
120
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
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.2",
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",
@@ -315,6 +315,8 @@ export interface FGCAPreviewNode {
315
315
  };
316
316
  /** Marks a synthesized node (e.g. virtual Change) vs. a real entity. */
317
317
  virtual?: boolean;
318
+ /** For activity nodes: true if the activity is linked to a goal. */
319
+ linked?: boolean;
318
320
  }
319
321
  export interface FGCAPreviewEdge {
320
322
  sx: number;
@@ -369,6 +371,7 @@ export function layoutFGCAPreview(
369
371
 
370
372
  // Synthesize virtual Change nodes for activities without a real Change.
371
373
  // Each virtual node is 1:1 with one activity (not shared across activities).
374
+ // The layout key embeds the activity id for uniqueness; it is not a display id.
372
375
  const virtualChanges: Array<{ id: string; activityId: string; goalId: string }> = [];
373
376
  for (const a of doc.activities) {
374
377
  if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
@@ -377,14 +380,14 @@ export function layoutFGCAPreview(
377
380
  }
378
381
  }
379
382
 
380
- const colItems: Record<FGCAPreviewColumn, Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }>> = {
383
+ const colItems: Record<FGCAPreviewColumn, Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }>> = {
381
384
  driver: doc.factors.map(f => ({ id: `driver_${f.id}`, label: f.name })),
382
385
  goal: doc.goals.map(g => ({ id: `goal_${g.id}`, label: g.name })),
383
386
  change: [
384
387
  ...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
385
388
  ...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
386
389
  ],
387
- activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress })),
390
+ activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: a.goal_id != null })),
388
391
  };
389
392
 
390
393
  // Build predecessor map: for each node, which node IDs in the previous column
@@ -429,9 +432,9 @@ export function layoutFGCAPreview(
429
432
  // Nodes with no predecessors sort last (Infinity barycenter) so they don't
430
433
  // displace connected nodes.
431
434
  function barycentricSort(
432
- items: Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }>,
435
+ items: Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }>,
433
436
  yCenters: Map<string, number>,
434
- ): Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }> {
437
+ ): Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }> {
435
438
  return [...items].sort((a, b) => {
436
439
  const pA = (predecessors.get(a.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
437
440
  const pB = (predecessors.get(b.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
@@ -462,6 +465,7 @@ export function layoutFGCAPreview(
462
465
  type: item.type,
463
466
  progress: item.progress,
464
467
  virtual: item.virtual,
468
+ linked: item.linked,
465
469
  };
466
470
  nodes.push(node);
467
471
  nodeMap.set(item.id, node);
@@ -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
  }
@@ -165,7 +165,8 @@ export interface LayoutCenteredEntityOptions {
165
165
  boxHeight: number;
166
166
  name: string;
167
167
  type?: string;
168
- id: string;
168
+ /** Canonical entity id. Empty or omitted skips the id row (placeholder nodes). */
169
+ id?: string;
169
170
  nameMaxLines?: number;
170
171
  idMaxLines?: number;
171
172
  marginX?: number;
@@ -189,8 +190,15 @@ interface TextRowGroup {
189
190
  * to avoid overlap.
190
191
  */
191
192
  const HALF_EXTENT_PRIMARY = 6; // text-primary, 12px font
193
+ const HALF_EXTENT_SECONDARY = 6; // text-secondary, same size as name
192
194
  const HALF_EXTENT_ID = 5; // text-id, 10px font
193
195
 
196
+ function glyphHalfExtent(cls: string): number {
197
+ if (cls === 'text-id') return HALF_EXTENT_ID;
198
+ if (cls === 'text-secondary') return HALF_EXTENT_SECONDARY;
199
+ return HALF_EXTENT_PRIMARY;
200
+ }
201
+
194
202
  /** Place `groups`' lines starting at `startY`, advancing by each line's own height and gap. */
195
203
  function placeLines(groups: TextRowGroup[], startY: number): TextLineSpec[] {
196
204
  const out: TextLineSpec[] = [];
@@ -234,8 +242,11 @@ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): Tex
234
242
  const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
235
243
 
236
244
  const typeLine = opts.type?.trim() ? truncateLine(opts.type, maxCharsType) : undefined;
245
+ const idText = opts.id?.trim() ?? '';
237
246
  const idMaxLines = opts.idMaxLines ?? 1;
238
- const idLines = idMaxLines <= 1 ? [truncateId(opts.id, maxCharsId)] : wrapId(opts.id, maxCharsId, idMaxLines);
247
+ const idLines = idText
248
+ ? (idMaxLines <= 1 ? [truncateId(idText, maxCharsId)] : wrapId(idText, maxCharsId, idMaxLines))
249
+ : [];
239
250
 
240
251
  function buildGroups(nameLines: string[]): TextRowGroup[] {
241
252
  const groups: TextRowGroup[] = [
@@ -243,7 +254,7 @@ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): Tex
243
254
  cls: 'text-primary',
244
255
  lines: nameLines,
245
256
  lineHeight: LINE_H_PRIMARY,
246
- gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
257
+ gapAfter: typeLine ? ROW_GROUP_GAP : idLines.length > 0 ? NAME_ID_GAP : 0,
247
258
  },
248
259
  ];
249
260
  if (typeLine) {
@@ -251,26 +262,28 @@ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): Tex
251
262
  cls: 'text-secondary',
252
263
  lines: [typeLine],
253
264
  lineHeight: LINE_H_SECONDARY,
254
- gapAfter: NAME_ID_GAP,
265
+ gapAfter: idLines.length > 0 ? NAME_ID_GAP : 0,
266
+ });
267
+ }
268
+ if (idLines.length > 0) {
269
+ groups.push({
270
+ cls: 'text-id',
271
+ lines: idLines,
272
+ lineHeight: LINE_H_ID,
273
+ gapAfter: 0,
255
274
  });
256
275
  }
257
- groups.push({
258
- cls: 'text-id',
259
- lines: idLines,
260
- lineHeight: LINE_H_ID,
261
- gapAfter: 0,
262
- });
263
276
  return groups;
264
277
  }
265
278
 
266
- // groups[0] is always the name (text-primary) and the last group is always
267
- // the id (text-id), so the edge-padding half-extents below are fixed.
279
+ // groups[0] is always the name (text-primary). The last group is the id when
280
+ // one is present, otherwise type or name; edge padding uses that class.
268
281
  const requestedNameMaxLines = Math.max(1, opts.nameMaxLines ?? 2);
269
282
  const availableHeight = Math.max(0, opts.boxHeight - marginY * 2);
270
283
 
271
284
  let nameLines = wrapWords(opts.name, maxCharsName, requestedNameMaxLines);
272
285
  let groups = buildGroups(nameLines);
273
- let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
286
+ let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + glyphHalfExtent(groups[groups.length - 1].cls);
274
287
  for (
275
288
  let cap = requestedNameMaxLines - 1;
276
289
  cap >= 1 && contentSpan > availableHeight;
@@ -278,7 +291,7 @@ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): Tex
278
291
  ) {
279
292
  nameLines = wrapWords(opts.name, maxCharsName, cap);
280
293
  groups = buildGroups(nameLines);
281
- contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
294
+ contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + glyphHalfExtent(groups[groups.length - 1].cls);
282
295
  }
283
296
 
284
297
  const margin = Math.max(0, (opts.boxHeight - contentSpan) / 2);
@@ -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,6 +63,7 @@ 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));
66
68
  const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
67
69
 
@@ -80,7 +82,9 @@ export function renderFgcaBody(
80
82
 
81
83
  const nodeSvg = nodes
82
84
  .map((n) => {
83
- const entityId = n.id.slice(n.id.indexOf('_') + 1);
85
+ // Virtual Change nodes use a layout key such as `vchange_activity_<id>`;
86
+ // slicing at the first `_` would print `activity_<id>` on a Change.
87
+ const entityId = n.virtual ? '' : n.id.slice(n.id.indexOf('_') + 1);
84
88
  const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
85
89
  const specs = layoutCenteredEntityText({
86
90
  boxX: n.x,
@@ -93,18 +97,25 @@ export function renderFgcaBody(
93
97
  idMaxLines: 1,
94
98
  });
95
99
  const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
96
- const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
100
+ const titleContent = n.virtual
101
+ ? escXml(n.label)
102
+ : `${escXml(n.label)} (${escXml(entityId)})`;
97
103
  const typeBadge = n.type && n.col === 'activity'
98
104
  ? `<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
105
  : '';
100
- const progressBadge = n.progress && n.col === 'activity'
106
+ const progressBadge = showCompletionPercent && n.col === 'activity'
101
107
  ? (() => {
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
+ if (n.progress) {
109
+ const computedAt = new Date(n.progress.computedAt);
110
+ const now = new Date();
111
+ const isStale = now.getTime() - computedAt.getTime() > STALENESS_THRESHOLD_MS;
112
+ const opacity = isStale ? ' opacity="0.5"' : '';
113
+ 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>`;
114
+ }
115
+ if (n.linked) {
116
+ return `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10">–%</text>`;
117
+ }
118
+ return '';
108
119
  })()
109
120
  : '';
110
121
  return [
@@ -136,6 +147,8 @@ export interface RenderFgcaOptions {
136
147
  layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
137
148
  /** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
138
149
  scopeCaption?: boolean;
150
+ /** Show completion percent badges on action nodes. */
151
+ showCompletionPercent?: boolean;
139
152
  }
140
153
 
141
154
  export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): string {
@@ -148,6 +161,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
148
161
  nodeSizePreset = 'normal',
149
162
  layoutOptions,
150
163
  scopeCaption = false,
164
+ showCompletionPercent = true,
151
165
  } = options;
152
166
  const hideChanges = variant === 'dga' || doc.hideChanges === true;
153
167
  const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
@@ -167,6 +181,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
167
181
  nodeWidth: nodeSize.width,
168
182
  nodeHeight: nodeSize.height,
169
183
  edgeStyle,
184
+ showCompletionPercent,
170
185
  });
171
186
 
172
187
  const titleSvg = title