@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.
- package/dist/fgca/preview-layout.d.ts +2 -0
- package/dist/fgca/preview-layout.js +3 -1
- package/dist/fgca/resolver.d.ts +4 -0
- package/dist/fgca/resolver.js +1 -0
- package/dist/webview/entity-text-layout.d.ts +2 -1
- package/dist/webview/entity-text-layout.js +26 -13
- package/dist/webview/render-fgca.d.ts +3 -0
- package/dist/webview/render-fgca.js +21 -10
- package/package.json +2 -2
- package/src/fgca/preview-layout.ts +8 -4
- package/src/fgca/resolver.ts +2 -0
- package/src/webview/entity-text-layout.ts +27 -14
- package/src/webview/render-fgca.ts +24 -9
|
@@ -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);
|
package/dist/fgca/resolver.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface FGCAViewConfig {
|
|
|
33
33
|
export interface FGCACanonSources {
|
|
34
34
|
elements: unknown[];
|
|
35
35
|
relations: unknown[];
|
|
36
|
+
progressData?: Map<string, {
|
|
37
|
+
percent: number;
|
|
38
|
+
computedAt: string;
|
|
39
|
+
}>;
|
|
36
40
|
}
|
|
37
41
|
/**
|
|
38
42
|
* Returns true when the parsed view document uses the canon-projection form
|
package/dist/fgca/resolver.js
CHANGED
|
@@ -64,7 +64,8 @@ export interface LayoutCenteredEntityOptions {
|
|
|
64
64
|
boxHeight: number;
|
|
65
65
|
name: string;
|
|
66
66
|
type?: string;
|
|
67
|
-
id
|
|
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 =
|
|
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)
|
|
232
|
-
//
|
|
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 +
|
|
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 +
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
72
|
+
const progressBadge = showCompletionPercent && n.col === 'activity'
|
|
68
73
|
? (() => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
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.
|
|
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);
|
package/src/fgca/resolver.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface FGCAViewConfig {
|
|
|
26
26
|
export interface FGCACanonSources {
|
|
27
27
|
elements: unknown[];
|
|
28
28
|
relations: unknown[];
|
|
29
|
+
progressData?: Map<string, { percent: number; computedAt: string }>;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function isObject(v: unknown): v is Record<string, unknown> {
|
|
@@ -192,5 +193,6 @@ export function resolveFGCA(
|
|
|
192
193
|
view_config: viewDoc['view_config'],
|
|
193
194
|
__outOfScopeGoalIds: outOfScopeGoalIds,
|
|
194
195
|
__outOfScopeFactorIds: outOfScopeFactorIds,
|
|
196
|
+
__progressData: sources.progressData,
|
|
195
197
|
};
|
|
196
198
|
}
|
|
@@ -165,7 +165,8 @@ export interface LayoutCenteredEntityOptions {
|
|
|
165
165
|
boxHeight: number;
|
|
166
166
|
name: string;
|
|
167
167
|
type?: string;
|
|
168
|
-
id
|
|
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 =
|
|
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)
|
|
267
|
-
//
|
|
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 +
|
|
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 +
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
106
|
+
const progressBadge = showCompletionPercent && n.col === 'activity'
|
|
101
107
|
? (() => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|