@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.
- package/dist/activities/types.d.ts +5 -0
- package/dist/fgca/parse-canonical.js +1 -0
- package/dist/fgca/preview-layout.d.ts +1 -0
- package/dist/fgca/preview-layout.js +2 -1
- package/dist/fgca/types.d.ts +2 -0
- package/dist/webview/completion-percent.d.ts +10 -0
- package/dist/webview/completion-percent.js +12 -0
- package/dist/webview/entity-text-layout.d.ts +2 -1
- package/dist/webview/entity-text-layout.js +26 -13
- package/dist/webview/render-activities.js +2 -0
- package/dist/webview/render-fgca.d.ts +0 -14
- package/dist/webview/render-fgca.js +9 -17
- package/package.json +4 -4
- package/src/activities/types.ts +2 -0
- package/src/fgca/parse-canonical.ts +1 -0
- package/src/fgca/preview-layout.ts +3 -1
- package/src/fgca/types.ts +2 -0
- package/src/webview/completion-percent.ts +18 -0
- package/src/webview/entity-text-layout.ts +27 -14
- package/src/webview/render-activities.ts +2 -0
- package/src/webview/render-fgca.ts +9 -17
|
@@ -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
|
});
|
|
@@ -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.
|
|
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.
|
package/dist/fgca/types.d.ts
CHANGED
|
@@ -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
|
|
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);
|
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
58
|
+
"@types/react-dom": "^19.3.0",
|
|
59
59
|
"jsdom": "^29.1.1",
|
|
60
60
|
"react": "^19.2.8",
|
|
61
|
-
"react-dom": "^19.
|
|
61
|
+
"react-dom": "^19.3.0",
|
|
62
62
|
"reactflow": "^11.11.4",
|
|
63
63
|
"typescript": "^5.0.0",
|
|
64
64
|
"vitest": "^4.1.11"
|
package/src/activities/types.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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);
|
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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>`,
|