@transitrix/diagrams 1.13.1 → 1.13.3

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.
@@ -24,6 +24,11 @@ export interface Activity {
24
24
  resources_cost?: number;
25
25
  effort?: number;
26
26
  link?: string;
27
+ /** Derived display metadata, loaded from the model sidecar. */
28
+ progress?: {
29
+ percent: number;
30
+ computedAt: string;
31
+ };
27
32
  description?: string;
28
33
  delivers_changes?: string[];
29
34
  }
@@ -280,6 +280,7 @@ export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds
280
280
  name: String(el['name'] ?? ''),
281
281
  goal_id: goalRefs.length > 0 ? goalRefs[0] : null,
282
282
  ...(typeVal !== undefined ? { type: typeVal } : {}),
283
+ ...(typeof el['link'] === 'string' ? { link: el['link'] } : {}),
283
284
  ...(progressVal !== undefined ? { progress: progressVal } : {}),
284
285
  };
285
286
  });
@@ -19,6 +19,7 @@ export interface FGCAPreviewChange {
19
19
  activity_ids: Array<number | string>;
20
20
  }
21
21
  export interface FGCAPreviewActivity {
22
+ link?: string;
22
23
  id: number | string;
23
24
  name: string;
24
25
  goal_id?: number | string | null;
@@ -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, linked: a.goal_id != null })),
253
+ activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: Boolean(a.link?.trim()) })),
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.
@@ -24,6 +24,8 @@ 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
+ /** Tracker link, independent of goal relationships. */
28
+ link?: string;
27
29
  progress?: {
28
30
  percent: number;
29
31
  computedAt: string;
@@ -0,0 +1,10 @@
1
+ /** Shared completion display contract for Action, DGA and DGCA previews. */
2
+ export interface ActionProgress {
3
+ percent: number;
4
+ computedAt: string;
5
+ }
6
+ export declare function completionPercentState(progress: ActionProgress | undefined, linked: boolean, enabled?: boolean, now?: number): {
7
+ label: string;
8
+ stale: boolean;
9
+ } | undefined;
10
+ export declare function completionPercentSvg(progress: ActionProgress | undefined, linked: boolean, x: number, y: number, enabled?: boolean): string;
@@ -0,0 +1,12 @@
1
+ export function completionPercentState(progress, linked, enabled = true, now = Date.now()) {
2
+ if (!enabled)
3
+ return undefined;
4
+ if (progress && Number.isFinite(progress.percent) && progress.percent >= 0 && progress.percent <= 100 && Number.isFinite(Date.parse(progress.computedAt))) {
5
+ return { label: `${progress.percent}%`, stale: now - Date.parse(progress.computedAt) > 7 * 24 * 60 * 60 * 1000 };
6
+ }
7
+ return linked ? { label: '–%', stale: false } : undefined;
8
+ }
9
+ export function completionPercentSvg(progress, linked, x, y, enabled = true) {
10
+ const state = completionPercentState(progress, linked, enabled);
11
+ return state ? `<text class="text-id completion-percent" x="${x}" y="${y}" text-anchor="end" dominant-baseline="hanging" font-size="10"${state.stale ? ' opacity="0.5"' : ''}>${state.label}</text>` : '';
12
+ }
@@ -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);
@@ -16,6 +16,7 @@
16
16
  * in `renderActivitiesNetworkBody` and is shared verbatim with the VS Code
17
17
  * preview's `networkSvg`.
18
18
  */
19
+ import { completionPercentSvg } from './completion-percent.js';
19
20
  import { layoutActivities } from '../activities/layout.js';
20
21
  import { computeCpm } from '../activities/cpm.js';
21
22
  import { horizontalCubicEdgePath, bowedCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
@@ -127,6 +128,7 @@ function activityNodeSvg(n, x, y, isCritical, isMilestone, durLabel) {
127
128
  return [
128
129
  `<rect class="${cls}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="8"/>`,
129
130
  textSvg,
131
+ completionPercentSvg(n.data.progress, Boolean(n.data.link?.trim()), x + n.width - 8, y + 6),
130
132
  durLabel ? `<text class="text-secondary" x="${x + n.width - 8}" y="${y + n.height - 8}" text-anchor="end">${durLabel}</text>` : '',
131
133
  ].filter(Boolean).join('\n');
132
134
  }
@@ -1,17 +1,3 @@
1
- /**
2
- * Browser-safe SVG renderer for the canonical DGCA and DGA notations.
3
- *
4
- * Step 4 of the IntelliJ epic (ADR 0001): the webview bundle must turn a
5
- * validated DGCA/DGA document into renderable SVG so JCEF can drop it into the
6
- * preview panel. The VS Code path lives in `extension/src/dgca-preview.ts` and
7
- * pulls in VS Code-specific concerns (themes, title block, save dialogs,
8
- * spacing/scope controls); this module is the host-neutral subset — pure
9
- * column layout → SVG with no VS Code APIs, no `node:fs`, no `node:path`.
10
- *
11
- * DGCA and DGA share the same renderer; DGA only differs by hiding the
12
- * `change` column (`hideChanges` / the layout's Goal → Activity collapse),
13
- * exactly as the extension preview does.
14
- */
15
1
  import { layoutFGCAPreview } from '../fgca/preview-layout.js';
16
2
  import type { FGCADoc } from '../fgca/validate.js';
17
3
  import { type EdgeStyle } from '../edge-path.js';
@@ -12,6 +12,7 @@
12
12
  * `change` column (`hideChanges` / the layout's Goal → Activity collapse),
13
13
  * exactly as the extension preview does.
14
14
  */
15
+ import { completionPercentSvg } from './completion-percent.js';
15
16
  import { layoutFGCAPreview, FGCA_NODE_W, FGCA_NODE_H, FGCA_HEADER_H as HEADER_H, FGCA_PAD as PAD, } from '../fgca/preview-layout.js';
16
17
  import { previewEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
17
18
  import { parseNodeSizePreset, resolveDgcaNodeSize } from '../node-size-presets.js';
@@ -39,7 +40,6 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
39
40
  const edgeStyle = bodyOptions.edgeStyle;
40
41
  const showCompletionPercent = bodyOptions.showCompletionPercent ?? true;
41
42
  const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
42
- const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
43
43
  const headerSvg = columns
44
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>`)
45
45
  .join('\n');
@@ -48,7 +48,9 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
48
48
  .join('\n');
49
49
  const nodeSvg = nodes
50
50
  .map((n) => {
51
- 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);
52
54
  const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
53
55
  const specs = layoutCenteredEntityText({
54
56
  boxX: n.x,
@@ -61,24 +63,14 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
61
63
  idMaxLines: 1,
62
64
  });
63
65
  const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
64
- const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
66
+ const titleContent = n.virtual
67
+ ? escXml(n.label)
68
+ : `${escXml(n.label)} (${escXml(entityId)})`;
65
69
  const typeBadge = n.type && n.col === 'activity'
66
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>`
67
71
  : '';
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
- })()
72
+ const progressBadge = n.col === 'activity'
73
+ ? completionPercentSvg(n.progress, Boolean(n.linked), n.x + nodeWidth - 6, n.y + 22, showCompletionPercent)
82
74
  : '';
83
75
  return [
84
76
  `<g>`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.13.1",
3
+ "version": "1.13.3",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -52,13 +52,13 @@
52
52
  "reactflow": "^11.0.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@testing-library/dom": "^10.4.1",
55
+ "@testing-library/dom": "^10.4.2",
56
56
  "@testing-library/react": "^16.3.3",
57
57
  "@types/react": "^19.2.17",
58
- "@types/react-dom": "^19.2.5",
58
+ "@types/react-dom": "^19.3.0",
59
59
  "jsdom": "^29.1.1",
60
60
  "react": "^19.2.8",
61
- "react-dom": "^19.2.8",
61
+ "react-dom": "^19.3.0",
62
62
  "reactflow": "^11.11.4",
63
63
  "typescript": "^5.0.0",
64
64
  "vitest": "^4.1.11"
@@ -33,6 +33,8 @@ export interface Activity {
33
33
  resources_cost?: number;
34
34
  effort?: number;
35
35
  link?: string;
36
+ /** Derived display metadata, loaded from the model sidecar. */
37
+ progress?: { percent: number; computedAt: string };
36
38
  description?: string;
37
39
  delivers_changes?: string[];
38
40
  }
@@ -332,6 +332,7 @@ export function parseCanonicalFGCA(
332
332
  name: String(el!['name'] ?? ''),
333
333
  goal_id: goalRefs.length > 0 ? goalRefs[0] : null,
334
334
  ...(typeVal !== undefined ? { type: typeVal } : {}),
335
+ ...(typeof el!['link'] === 'string' ? { link: el!['link'] } : {}),
335
336
  ...(progressVal !== undefined ? { progress: progressVal } : {}),
336
337
  };
337
338
  });
@@ -30,6 +30,7 @@ export interface FGCAPreviewChange {
30
30
  activity_ids: Array<number | string>;
31
31
  }
32
32
  export interface FGCAPreviewActivity {
33
+ link?: string;
33
34
  id: number | string;
34
35
  name: string;
35
36
  goal_id?: number | string | null;
@@ -371,6 +372,7 @@ export function layoutFGCAPreview(
371
372
 
372
373
  // Synthesize virtual Change nodes for activities without a real Change.
373
374
  // Each virtual node is 1:1 with one activity (not shared across activities).
375
+ // The layout key embeds the activity id for uniqueness; it is not a display id.
374
376
  const virtualChanges: Array<{ id: string; activityId: string; goalId: string }> = [];
375
377
  for (const a of doc.activities) {
376
378
  if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
@@ -386,7 +388,7 @@ export function layoutFGCAPreview(
386
388
  ...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
387
389
  ...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
388
390
  ],
389
- activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: a.goal_id != null })),
391
+ activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: Boolean(a.link?.trim()) })),
390
392
  };
391
393
 
392
394
  // Build predecessor map: for each node, which node IDs in the previous column
package/src/fgca/types.ts CHANGED
@@ -25,6 +25,8 @@ 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
+ /** Tracker link, independent of goal relationships. */
29
+ link?: string;
28
30
  progress?: {
29
31
  percent: number;
30
32
  computedAt: string;
@@ -0,0 +1,18 @@
1
+ /** Shared completion display contract for Action, DGA and DGCA previews. */
2
+ export interface ActionProgress {
3
+ percent: number;
4
+ computedAt: string;
5
+ }
6
+
7
+ export function completionPercentState(progress: ActionProgress | undefined, linked: boolean, enabled = true, now = Date.now()): { label: string; stale: boolean } | undefined {
8
+ if (!enabled) return undefined;
9
+ if (progress && Number.isFinite(progress.percent) && progress.percent >= 0 && progress.percent <= 100 && Number.isFinite(Date.parse(progress.computedAt))) {
10
+ return { label: `${progress.percent}%`, stale: now - Date.parse(progress.computedAt) > 7 * 24 * 60 * 60 * 1000 };
11
+ }
12
+ return linked ? { label: '–%', stale: false } : undefined;
13
+ }
14
+
15
+ export function completionPercentSvg(progress: ActionProgress | undefined, linked: boolean, x: number, y: number, enabled = true): string {
16
+ const state = completionPercentState(progress, linked, enabled);
17
+ return state ? `<text class="text-id completion-percent" x="${x}" y="${y}" text-anchor="end" dominant-baseline="hanging" font-size="10"${state.stale ? ' opacity="0.5"' : ''}>${state.label}</text>` : '';
18
+ }
@@ -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);
@@ -16,6 +16,7 @@
16
16
  * in `renderActivitiesNetworkBody` and is shared verbatim with the VS Code
17
17
  * preview's `networkSvg`.
18
18
  */
19
+ import { completionPercentSvg } from './completion-percent.js';
19
20
  import { layoutActivities } from '../activities/layout.js';
20
21
  import { computeCpm } from '../activities/cpm.js';
21
22
  import type {
@@ -147,6 +148,7 @@ function activityNodeSvg(
147
148
  return [
148
149
  `<rect class="${cls}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="8"/>`,
149
150
  textSvg,
151
+ completionPercentSvg(n.data.progress, Boolean(n.data.link?.trim()), x + n.width - 8, y + 6),
150
152
  durLabel ? `<text class="text-secondary" x="${x + n.width - 8}" y="${y + n.height - 8}" text-anchor="end">${durLabel}</text>` : '',
151
153
  ].filter(Boolean).join('\n');
152
154
  }
@@ -12,6 +12,7 @@
12
12
  * `change` column (`hideChanges` / the layout's Goal → Activity collapse),
13
13
  * exactly as the extension preview does.
14
14
  */
15
+ import { completionPercentSvg } from './completion-percent.js';
15
16
  import {
16
17
  layoutFGCAPreview,
17
18
  FGCA_NODE_W,
@@ -65,7 +66,6 @@ export function renderFgcaBody(
65
66
  const edgeStyle = bodyOptions.edgeStyle;
66
67
  const showCompletionPercent = bodyOptions.showCompletionPercent ?? true;
67
68
  const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
68
- const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
69
69
 
70
70
  const headerSvg = columns
71
71
  .map(({ col, x }) =>
@@ -82,7 +82,9 @@ export function renderFgcaBody(
82
82
 
83
83
  const nodeSvg = nodes
84
84
  .map((n) => {
85
- 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);
86
88
  const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
87
89
  const specs = layoutCenteredEntityText({
88
90
  boxX: n.x,
@@ -95,24 +97,14 @@ export function renderFgcaBody(
95
97
  idMaxLines: 1,
96
98
  });
97
99
  const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
98
- const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
100
+ const titleContent = n.virtual
101
+ ? escXml(n.label)
102
+ : `${escXml(n.label)} (${escXml(entityId)})`;
99
103
  const typeBadge = n.type && n.col === 'activity'
100
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>`
101
105
  : '';
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
- })()
106
+ const progressBadge = n.col === 'activity'
107
+ ? completionPercentSvg(n.progress, Boolean(n.linked), n.x + nodeWidth - 6, n.y + 22, showCompletionPercent)
116
108
  : '';
117
109
  return [
118
110
  `<g>`,