@transitrix/diagrams 1.13.0 → 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.
@@ -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;
@@ -249,7 +249,7 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
249
249
  ...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
250
250
  ...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
251
251
  ],
252
- activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress })),
252
+ activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: a.goal_id != null })),
253
253
  };
254
254
  // Build predecessor map: for each node, which node IDs in the previous column
255
255
  // connect to it from the left. Used for barycenter crossing minimization.
@@ -321,6 +321,7 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
321
321
  type: item.type,
322
322
  progress: item.progress,
323
323
  virtual: item.virtual,
324
+ linked: item.linked,
324
325
  };
325
326
  nodes.push(node);
326
327
  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
  }
@@ -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
@@ -64,14 +65,19 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
64
65
  const typeBadge = n.type && n.col === 'activity'
65
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>`
66
67
  : '';
67
- const progressBadge = n.progress && n.col === 'activity'
68
+ const progressBadge = showCompletionPercent && n.col === 'activity'
68
69
  ? (() => {
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>`;
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 '';
75
81
  })()
76
82
  : '';
77
83
  return [
@@ -88,7 +94,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
88
94
  return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
89
95
  }
90
96
  export function renderFgcaSvg(doc, options = {}) {
91
- 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;
92
98
  const hideChanges = variant === 'dga' || doc.hideChanges === true;
93
99
  const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
94
100
  const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, {
@@ -104,6 +110,7 @@ export function renderFgcaSvg(doc, options = {}) {
104
110
  nodeWidth: nodeSize.width,
105
111
  nodeHeight: nodeSize.height,
106
112
  edgeStyle,
113
+ showCompletionPercent,
107
114
  });
108
115
  const titleSvg = title
109
116
  ? `<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.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.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;
@@ -377,14 +379,14 @@ export function layoutFGCAPreview(
377
379
  }
378
380
  }
379
381
 
380
- const colItems: Record<FGCAPreviewColumn, Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }>> = {
382
+ const colItems: Record<FGCAPreviewColumn, Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }>> = {
381
383
  driver: doc.factors.map(f => ({ id: `driver_${f.id}`, label: f.name })),
382
384
  goal: doc.goals.map(g => ({ id: `goal_${g.id}`, label: g.name })),
383
385
  change: [
384
386
  ...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
385
387
  ...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
386
388
  ],
387
- activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress })),
389
+ activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress, linked: a.goal_id != null })),
388
390
  };
389
391
 
390
392
  // Build predecessor map: for each node, which node IDs in the previous column
@@ -429,9 +431,9 @@ export function layoutFGCAPreview(
429
431
  // Nodes with no predecessors sort last (Infinity barycenter) so they don't
430
432
  // displace connected nodes.
431
433
  function barycentricSort(
432
- items: Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }>,
434
+ items: Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }>,
433
435
  yCenters: Map<string, number>,
434
- ): Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }> {
436
+ ): Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean; linked?: boolean }> {
435
437
  return [...items].sort((a, b) => {
436
438
  const pA = (predecessors.get(a.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
437
439
  const pB = (predecessors.get(b.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
@@ -462,6 +464,7 @@ export function layoutFGCAPreview(
462
464
  type: item.type,
463
465
  progress: item.progress,
464
466
  virtual: item.virtual,
467
+ linked: item.linked,
465
468
  };
466
469
  nodes.push(node);
467
470
  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
  }
@@ -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
 
@@ -97,14 +99,19 @@ export function renderFgcaBody(
97
99
  const typeBadge = n.type && n.col === 'activity'
98
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>`
99
101
  : '';
100
- const progressBadge = n.progress && n.col === 'activity'
102
+ const progressBadge = showCompletionPercent && n.col === 'activity'
101
103
  ? (() => {
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>`;
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 '';
108
115
  })()
109
116
  : '';
110
117
  return [
@@ -136,6 +143,8 @@ export interface RenderFgcaOptions {
136
143
  layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
137
144
  /** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
138
145
  scopeCaption?: boolean;
146
+ /** Show completion percent badges on action nodes. */
147
+ showCompletionPercent?: boolean;
139
148
  }
140
149
 
141
150
  export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): string {
@@ -148,6 +157,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
148
157
  nodeSizePreset = 'normal',
149
158
  layoutOptions,
150
159
  scopeCaption = false,
160
+ showCompletionPercent = true,
151
161
  } = options;
152
162
  const hideChanges = variant === 'dga' || doc.hideChanges === true;
153
163
  const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
@@ -167,6 +177,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
167
177
  nodeWidth: nodeSize.width,
168
178
  nodeHeight: nodeSize.height,
169
179
  edgeStyle,
180
+ showCompletionPercent,
170
181
  });
171
182
 
172
183
  const titleSvg = title