@transitrix/diagrams 1.1.0 → 1.3.0
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/__tests__/validate.test.js +32 -1
- package/dist/activities/validate.js +21 -5
- package/dist/activity-card/__tests__/example.test.js +1 -3
- package/dist/activity-card/__tests__/layout.test.js +2 -9
- package/dist/activity-card/__tests__/resolver.test.js +26 -1
- package/dist/activity-card/__tests__/validate.test.js +23 -1
- package/dist/activity-card/layout.js +1 -7
- package/dist/activity-card/resolver.js +34 -23
- package/dist/activity-card/types.d.ts +1 -1
- package/dist/activity-card/validate.js +22 -11
- package/dist/capability-map/index.d.ts +4 -0
- package/dist/capability-map/index.js +2 -0
- package/dist/capability-map/layout-tree.d.ts +42 -0
- package/dist/capability-map/layout-tree.js +65 -0
- package/dist/capability-map/render-capability-tree.d.ts +15 -0
- package/dist/capability-map/render-capability-tree.js +108 -0
- package/dist/capability-map/types.d.ts +2 -0
- package/dist/fgca/__tests__/parse-canonical.test.js +14 -1
- package/dist/fgca/__tests__/validate.test.js +2 -2
- package/dist/fgca/parse-canonical.js +20 -24
- package/dist/fgca/preview-layout.js +50 -1
- package/dist/fgca/types.d.ts +2 -0
- package/dist/fgca/validate.js +19 -11
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/themes.js +26 -3
- package/dist/theme/tokens.d.ts +40 -0
- package/dist/theme/tokens.js +28 -0
- package/dist/webview/render-activity-card.js +2 -3
- package/dist/webview/render-fgca.js +2 -2
- package/dist/webview/render-goals.js +28 -5
- package/package.json +1 -1
- package/src/activities/__tests__/validate.test.ts +35 -1
- package/src/activities/validate.ts +21 -5
- package/src/activity-card/__tests__/example.test.ts +1 -3
- package/src/activity-card/__tests__/layout.test.ts +2 -9
- package/src/activity-card/__tests__/resolver.test.ts +28 -1
- package/src/activity-card/__tests__/validate.test.ts +25 -1
- package/src/activity-card/layout.ts +1 -8
- package/src/activity-card/resolver.ts +34 -23
- package/src/activity-card/types.ts +1 -1
- package/src/activity-card/validate.ts +20 -11
- package/src/capability-map/index.ts +11 -0
- package/src/capability-map/layout-tree.ts +114 -0
- package/src/capability-map/render-capability-tree.ts +131 -0
- package/src/capability-map/types.ts +2 -0
- package/src/fgca/__tests__/parse-canonical.test.ts +15 -1
- package/src/fgca/__tests__/validate.test.ts +2 -2
- package/src/fgca/parse-canonical.ts +20 -30
- package/src/fgca/preview-layout.ts +53 -1
- package/src/fgca/types.ts +2 -0
- package/src/fgca/validate.ts +16 -8
- package/src/theme/index.ts +1 -1
- package/src/theme/themes.ts +27 -2
- package/src/theme/tokens.ts +30 -0
- package/src/webview/render-activity-card.ts +2 -3
- package/src/webview/render-fgca.ts +2 -2
- package/src/webview/render-goals.ts +26 -5
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { CapabilityMapHeader } from './types.js';
|
|
2
|
+
import { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_NODE_SEP } from './layout-tree.js';
|
|
3
|
+
import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
|
|
4
|
+
import { escXml } from '../webview/render-util.js';
|
|
5
|
+
|
|
6
|
+
const PAD = 24;
|
|
7
|
+
const BTN_R = 8;
|
|
8
|
+
const LEGEND_H = 40;
|
|
9
|
+
const LABEL_MAX_CHARS = 28;
|
|
10
|
+
const LABEL_TRIM_CHARS = 26;
|
|
11
|
+
|
|
12
|
+
const BAND_LABELS = ['Levels 0–2', 'Levels 3–4', 'Levels 5+'] as const;
|
|
13
|
+
|
|
14
|
+
function depthBand(depth: number): 0 | 1 | 2 {
|
|
15
|
+
if (depth <= 2) return 0;
|
|
16
|
+
if (depth <= 4) return 1;
|
|
17
|
+
return 2;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function truncate(s: string, max: number, trim: number): string {
|
|
21
|
+
return s.length > max ? s.slice(0, trim) + '…' : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RenderCapabilityTreeOptions {
|
|
25
|
+
collapsedIds?: Set<string>;
|
|
26
|
+
curvature?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders a capability-map as a left-to-right SVG node tree with DSM-matching
|
|
31
|
+
* visual design: depth-banded fills, maturity badges, and +/− collapse buttons.
|
|
32
|
+
*
|
|
33
|
+
* The SVG uses CSS classes (`tree-level-N`, `tree-maturity-N`, `tree-collapse-btn`)
|
|
34
|
+
* that must be resolved by the host's stylesheet (injected via `diagramClassCss()`
|
|
35
|
+
* in themes.ts). Collapse buttons carry `data-node-id` / `data-collapsed` attributes
|
|
36
|
+
* for the webview click handler.
|
|
37
|
+
*/
|
|
38
|
+
export function renderCapabilityTreeSvg(
|
|
39
|
+
map: CapabilityMapHeader,
|
|
40
|
+
opts: RenderCapabilityTreeOptions = {},
|
|
41
|
+
): string {
|
|
42
|
+
const { collapsedIds = new Set(), curvature = DEFAULT_EDGE_CURVATURE } = opts;
|
|
43
|
+
const layout = layoutCapabilityTree(map, collapsedIds);
|
|
44
|
+
|
|
45
|
+
if (layout.nodes.length === 0) {
|
|
46
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ox = -layout.bounds.x + PAD;
|
|
50
|
+
const oy = -layout.bounds.y + PAD;
|
|
51
|
+
const svgW = layout.bounds.width + PAD * 2 + BTN_R;
|
|
52
|
+
const svgH = layout.bounds.height + PAD * 2 + LEGEND_H + TREE_NODE_SEP;
|
|
53
|
+
|
|
54
|
+
const nodeMap = new Map(layout.nodes.map(n => [n.id, n]));
|
|
55
|
+
|
|
56
|
+
const edgeSvg = layout.edges.map(e => {
|
|
57
|
+
const s = nodeMap.get(e.source);
|
|
58
|
+
const t = nodeMap.get(e.target);
|
|
59
|
+
if (!s || !t) return '';
|
|
60
|
+
const sx = s.x + ox + s.width;
|
|
61
|
+
const sy = s.y + oy + s.height / 2;
|
|
62
|
+
const tx = t.x + ox;
|
|
63
|
+
const ty = t.y + oy + t.height / 2;
|
|
64
|
+
return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature)}" class="diagram-edge"/>`;
|
|
65
|
+
}).filter(Boolean).join('\n');
|
|
66
|
+
|
|
67
|
+
const nodeSvg = layout.nodes.map(n => {
|
|
68
|
+
const x = n.x + ox;
|
|
69
|
+
const y = n.y + oy;
|
|
70
|
+
const band = depthBand(n.depth);
|
|
71
|
+
const mat = Math.max(1, Math.min(5, n.data.current_maturity | 0));
|
|
72
|
+
|
|
73
|
+
const nameText = truncate(n.data.name, LABEL_MAX_CHARS, LABEL_TRIM_CHARS);
|
|
74
|
+
const nameTitle = n.data.name.length > LABEL_MAX_CHARS ? escXml(n.data.name) : '';
|
|
75
|
+
|
|
76
|
+
// Maturity badge: left side of node
|
|
77
|
+
const badgeW = 28;
|
|
78
|
+
const badgeH = 18;
|
|
79
|
+
const badgeX = x + 10;
|
|
80
|
+
const badgeY = y + (TREE_NODE_HEIGHT - badgeH) / 2;
|
|
81
|
+
|
|
82
|
+
// Text area: right of badge
|
|
83
|
+
const textX = badgeX + badgeW + 8;
|
|
84
|
+
const nameY = y + TREE_NODE_HEIGHT / 2 - 8;
|
|
85
|
+
const idY = y + TREE_NODE_HEIGHT / 2 + 9;
|
|
86
|
+
|
|
87
|
+
// Collapse button: centered on right edge of node
|
|
88
|
+
const btnCx = x + TREE_NODE_WIDTH;
|
|
89
|
+
const btnCy = y + TREE_NODE_HEIGHT / 2;
|
|
90
|
+
|
|
91
|
+
const collapseBtn = n.hasChildren
|
|
92
|
+
? `<circle class="tree-collapse-btn" cx="${btnCx}" cy="${btnCy}" r="${BTN_R}" data-node-id="${escXml(n.id)}" data-collapsed="${n.isCollapsed}"/>
|
|
93
|
+
<text class="tree-collapse-lbl" x="${btnCx}" y="${btnCy}" text-anchor="middle">${n.isCollapsed ? '+' : '−'}</text>`
|
|
94
|
+
: '';
|
|
95
|
+
|
|
96
|
+
return `<g${nameTitle ? ` title="${nameTitle}"` : ''}>
|
|
97
|
+
<rect class="tree-level-${band}" x="${x}" y="${y}" width="${TREE_NODE_WIDTH}" height="${TREE_NODE_HEIGHT}" rx="11" stroke="var(--ts-node-stroke,#94a3b8)" stroke-width="3"/>
|
|
98
|
+
<rect class="tree-maturity-${mat}" x="${badgeX}" y="${badgeY}" width="${badgeW}" height="${badgeH}" rx="4"/>
|
|
99
|
+
<text class="text-pill" x="${badgeX + badgeW / 2}" y="${badgeY + badgeH / 2}" text-anchor="middle" fill="white">L${mat}</text>
|
|
100
|
+
<text class="text-primary" x="${textX}" y="${nameY}" dominant-baseline="central">${escXml(nameText)}</text>
|
|
101
|
+
<text class="text-id" x="${textX}" y="${idY}" dominant-baseline="central">${escXml(n.id)}</text>
|
|
102
|
+
${collapseBtn}
|
|
103
|
+
</g>`;
|
|
104
|
+
}).join('\n');
|
|
105
|
+
|
|
106
|
+
// Legend band: proportional coloured blocks per level-count band
|
|
107
|
+
const legendY = layout.bounds.height + PAD * 2 + TREE_NODE_SEP / 2;
|
|
108
|
+
const legendW = svgW - PAD * 2;
|
|
109
|
+
const totalNodes = layout.nodes.length || 1;
|
|
110
|
+
const bandCounts = [layout.levelCounts.band0, layout.levelCounts.band1, layout.levelCounts.band2];
|
|
111
|
+
let legendX = PAD;
|
|
112
|
+
const legendSvg = bandCounts.map((count, i) => {
|
|
113
|
+
const bw = Math.max(1, Math.round(legendW * count / totalNodes));
|
|
114
|
+
const bx = legendX;
|
|
115
|
+
legendX += bw;
|
|
116
|
+
if (count === 0) return '';
|
|
117
|
+
return `<rect class="tree-level-${i}" x="${bx}" y="${legendY}" width="${bw}" height="24" rx="4" stroke="var(--ts-node-stroke,#94a3b8)" stroke-width="1"/>
|
|
118
|
+
<text class="text-id" x="${bx + bw / 2}" y="${legendY + 12}" text-anchor="middle" dominant-baseline="central">${escXml(BAND_LABELS[i])}</text>`;
|
|
119
|
+
}).filter(Boolean).join('\n');
|
|
120
|
+
|
|
121
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
|
|
122
|
+
<defs>
|
|
123
|
+
<marker id="cap-tree-arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
|
|
124
|
+
<path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
|
|
125
|
+
</marker>
|
|
126
|
+
</defs>
|
|
127
|
+
${edgeSvg}
|
|
128
|
+
${nodeSvg}
|
|
129
|
+
${legendSvg}
|
|
130
|
+
</svg>`;
|
|
131
|
+
}
|
|
@@ -159,9 +159,23 @@ describe('parseCanonicalFGCA — edge-driving fields populated', () => {
|
|
|
159
159
|
expect(parsed.goals[0].factor).toHaveLength(1);
|
|
160
160
|
// goal → change edge
|
|
161
161
|
expect(parsed.changes[0].goal_id).not.toBe(0);
|
|
162
|
+
expect(parsed.changes[0].goal_id).not.toBe('');
|
|
162
163
|
// change → activity edge
|
|
163
164
|
expect(parsed.changes[0].activity_ids).toHaveLength(1);
|
|
164
165
|
});
|
|
166
|
+
|
|
167
|
+
it('preserves canonical string IDs — not converted to sequential numbers', () => {
|
|
168
|
+
const r = parseCanonicalFGCA(VALID);
|
|
169
|
+
expect(r.valid).toBe(true);
|
|
170
|
+
const parsed = r.parsed!;
|
|
171
|
+
expect(parsed.factors[0].id).toBe('FACTOR-1');
|
|
172
|
+
expect(parsed.goals[0].id).toBe('GOAL-1');
|
|
173
|
+
expect(parsed.goals[0].factor![0].id).toBe('FACTOR-1');
|
|
174
|
+
expect(parsed.changes[0].id).toBe('CHANGE-1');
|
|
175
|
+
expect(parsed.changes[0].goal_id).toBe('GOAL-1');
|
|
176
|
+
expect(parsed.changes[0].activity_ids[0]).toBe('ACTIVITY-1');
|
|
177
|
+
expect(parsed.activities[0].id).toBe('ACTIVITY-1');
|
|
178
|
+
});
|
|
165
179
|
});
|
|
166
180
|
|
|
167
181
|
describe('parseCanonicalFGA', () => {
|
|
@@ -242,7 +256,7 @@ describe('parseCanonicalFGA — example file regression', () => {
|
|
|
242
256
|
expect(r.errors).toEqual([]);
|
|
243
257
|
expect(r.valid).toBe(true);
|
|
244
258
|
// Every activity must carry a goal_id, or the FGA preview draws no edges.
|
|
245
|
-
expect(r.parsed!.activities.every((a) => a.goal_id != null && a.goal_id !== 0)).toBe(true);
|
|
259
|
+
expect(r.parsed!.activities.every((a) => a.goal_id != null && a.goal_id !== 0 && a.goal_id !== '')).toBe(true);
|
|
246
260
|
});
|
|
247
261
|
}
|
|
248
262
|
});
|
|
@@ -56,11 +56,11 @@ describe('validateFGCADoc', () => {
|
|
|
56
56
|
expect(result.errors.some(e => e.path === 'changes')).toBe(true);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
it('errors when
|
|
59
|
+
it('errors when actions array is missing', () => {
|
|
60
60
|
const { activities: _, ...without } = VALID_DOC;
|
|
61
61
|
const result = validateFGCADoc({ ...without, notation: 'fgca' });
|
|
62
62
|
expect(result.valid).toBe(false);
|
|
63
|
-
expect(result.errors.some(e => e.path === '
|
|
63
|
+
expect(result.errors.some(e => e.path === 'actions')).toBe(true);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
it('errors on empty factor name', () => {
|
|
@@ -63,10 +63,6 @@ function asObjectArray(v: unknown): Array<Record<string, unknown>> | null {
|
|
|
63
63
|
return v.map((el) => (el && typeof el === 'object' ? (el as Record<string, unknown>) : null) as Record<string, unknown>);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
interface InternalIdMap {
|
|
67
|
-
/** Canonical typed-string ID → internal numeric ID for layout consumption. */
|
|
68
|
-
toInternal: Map<string, number>;
|
|
69
|
-
}
|
|
70
66
|
|
|
71
67
|
/**
|
|
72
68
|
* Validate canonical FGCA YAML and, on success, return an internal-form
|
|
@@ -122,12 +118,16 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
122
118
|
const factorsRaw = asObjectArray(raw['factors']);
|
|
123
119
|
const goalsRaw = asObjectArray(raw['goals']);
|
|
124
120
|
const changesRaw = asObjectArray(raw['changes']);
|
|
125
|
-
|
|
121
|
+
// Accept canonical `actions:` or deprecated `activities:`; warn on the latter.
|
|
122
|
+
const activitiesRaw = asObjectArray(raw['actions']) ?? asObjectArray(raw['activities']);
|
|
123
|
+
if ('activities' in raw && !('actions' in raw)) {
|
|
124
|
+
warnings.push({ code: 'FGCA-001', message: '"activities" key is deprecated — rename to "actions"' });
|
|
125
|
+
}
|
|
126
126
|
|
|
127
127
|
if (factorsRaw === null) errors.push({ code: 'FGCA-004', message: 'factors must be an array', path: 'factors' });
|
|
128
128
|
if (goalsRaw === null) errors.push({ code: 'FGCA-004', message: 'goals must be an array', path: 'goals' });
|
|
129
129
|
if (changesRaw === null) errors.push({ code: 'FGCA-004', message: 'changes must be an array', path: 'changes' });
|
|
130
|
-
if (activitiesRaw === null) errors.push({ code: 'FGCA-004', message: '
|
|
130
|
+
if (activitiesRaw === null) errors.push({ code: 'FGCA-004', message: 'actions must be an array', path: 'actions' });
|
|
131
131
|
|
|
132
132
|
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
133
133
|
|
|
@@ -219,32 +219,20 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
219
219
|
return out;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
allCanonicalIds.forEach((id, i) => idMap.toInternal.set(id, i + 1));
|
|
226
|
-
|
|
227
|
-
function intId(canonical: string): number {
|
|
228
|
-
const n = idMap.toInternal.get(canonical);
|
|
229
|
-
if (n === undefined) {
|
|
230
|
-
// Shouldn't happen — we built the map from the same sets.
|
|
231
|
-
throw new Error(`internal: canonical id "${canonical}" not mapped`);
|
|
232
|
-
}
|
|
233
|
-
return n;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Layer entries (internal form).
|
|
222
|
+
// Layer entries (internal form). Canonical string IDs are passed through
|
|
223
|
+
// directly — no conversion to sequential numbers. The layout and renderer
|
|
224
|
+
// treat IDs as opaque keys and also display them verbatim to the user.
|
|
237
225
|
const internalFactors: DriverItem[] = factors.map((el) => ({
|
|
238
|
-
id:
|
|
226
|
+
id: String(el!['id']),
|
|
239
227
|
name: String(el!['name'] ?? ''),
|
|
240
228
|
}));
|
|
241
229
|
|
|
242
230
|
const internalGoals: GoalItem[] = goals.map((el, i) => {
|
|
243
231
|
const refIds = checkRefArray(el!, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`);
|
|
244
232
|
return {
|
|
245
|
-
id:
|
|
233
|
+
id: String(el!['id']),
|
|
246
234
|
name: String(el!['name'] ?? ''),
|
|
247
|
-
factor: refIds.map((r) => ({ id:
|
|
235
|
+
factor: refIds.map((r) => ({ id: r })),
|
|
248
236
|
};
|
|
249
237
|
});
|
|
250
238
|
|
|
@@ -280,19 +268,21 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
280
268
|
const canonicalChangeId = String(el!['id']);
|
|
281
269
|
const linkedActivityIds = activitiesForChange.get(canonicalChangeId) ?? [];
|
|
282
270
|
return {
|
|
283
|
-
id:
|
|
271
|
+
id: canonicalChangeId,
|
|
284
272
|
name: String(el!['name'] ?? ''),
|
|
285
|
-
goal_id: goalRefs.length > 0 ?
|
|
286
|
-
activity_ids: linkedActivityIds
|
|
273
|
+
goal_id: goalRefs.length > 0 ? goalRefs[0] : '',
|
|
274
|
+
activity_ids: linkedActivityIds,
|
|
287
275
|
};
|
|
288
276
|
});
|
|
289
277
|
|
|
290
278
|
const internalActivities: ActivityItem[] = activities.map((el, i) => {
|
|
291
|
-
const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `
|
|
279
|
+
const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`);
|
|
280
|
+
const typeVal = typeof el!['type'] === 'string' ? el!['type'] : undefined;
|
|
292
281
|
return {
|
|
293
|
-
id:
|
|
282
|
+
id: String(el!['id']),
|
|
294
283
|
name: String(el!['name'] ?? ''),
|
|
295
|
-
goal_id: goalRefs.length > 0 ?
|
|
284
|
+
goal_id: goalRefs.length > 0 ? goalRefs[0] : null,
|
|
285
|
+
...(typeVal !== undefined ? { type: typeVal } : {}),
|
|
296
286
|
};
|
|
297
287
|
});
|
|
298
288
|
|
|
@@ -154,19 +154,71 @@ export function layoutFGCAPreview(
|
|
|
154
154
|
activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name })),
|
|
155
155
|
};
|
|
156
156
|
|
|
157
|
+
// Build predecessor map: for each node, which node IDs in the previous column
|
|
158
|
+
// connect to it from the left. Used for barycenter crossing minimization.
|
|
159
|
+
const predecessors = new Map<string, string[]>();
|
|
160
|
+
for (const g of doc.goals) {
|
|
161
|
+
predecessors.set(`goal_${g.id}`, (g.factor ?? []).map(f => `driver_${f.id}`));
|
|
162
|
+
}
|
|
163
|
+
if (!hideChanges) {
|
|
164
|
+
for (const c of changes) {
|
|
165
|
+
predecessors.set(`change_${c.id}`, [`goal_${c.goal_id}`]);
|
|
166
|
+
}
|
|
167
|
+
const coveredActs = new Set(changes.flatMap(c => c.activity_ids.map(String)));
|
|
168
|
+
for (const a of doc.activities) {
|
|
169
|
+
const preds = changes
|
|
170
|
+
.filter(c => c.activity_ids.map(String).includes(String(a.id)))
|
|
171
|
+
.map(c => `change_${c.id}`);
|
|
172
|
+
if (a.goal_id != null && !coveredActs.has(String(a.id))) preds.push(`goal_${a.goal_id}`);
|
|
173
|
+
predecessors.set(`activity_${a.id}`, preds);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
const connectedViaChange = new Set<string>();
|
|
177
|
+
for (const c of changes) {
|
|
178
|
+
for (const aid of c.activity_ids) {
|
|
179
|
+
predecessors.set(`activity_${aid}`, [`goal_${c.goal_id}`]);
|
|
180
|
+
connectedViaChange.add(String(aid));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const a of doc.activities) {
|
|
184
|
+
if (a.goal_id != null && !connectedViaChange.has(String(a.id))) {
|
|
185
|
+
predecessors.set(`activity_${a.id}`, [`goal_${a.goal_id}`]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Sort a column's items by the barycenter of their predecessors' y-centres.
|
|
191
|
+
// Nodes with no predecessors sort last (Infinity barycenter) so they don't
|
|
192
|
+
// displace connected nodes.
|
|
193
|
+
function barycentricSort(
|
|
194
|
+
items: Array<{ id: string; label: string }>,
|
|
195
|
+
yCenters: Map<string, number>,
|
|
196
|
+
): Array<{ id: string; label: string }> {
|
|
197
|
+
return [...items].sort((a, b) => {
|
|
198
|
+
const pA = (predecessors.get(a.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
|
|
199
|
+
const pB = (predecessors.get(b.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
|
|
200
|
+
const bcA = pA.length > 0 ? pA.reduce((s, v) => s + v, 0) / pA.length : Infinity;
|
|
201
|
+
const bcB = pB.length > 0 ? pB.reduce((s, v) => s + v, 0) / pB.length : Infinity;
|
|
202
|
+
return bcA - bcB;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
157
206
|
const nodeMap = new Map<string, FGCAPreviewNode>();
|
|
158
207
|
const nodes: FGCAPreviewNode[] = [];
|
|
159
208
|
const columns: FGCAPreviewColumnPos[] = [];
|
|
209
|
+
const yCenters = new Map<string, number>(); // nodeId → y + FGCA_NODE_H / 2
|
|
160
210
|
|
|
161
211
|
for (let ci = 0; ci < cols.length; ci++) {
|
|
162
212
|
const col = cols[ci];
|
|
163
213
|
const x = FGCA_PAD + ci * colStride;
|
|
164
214
|
columns.push({ col, x });
|
|
215
|
+
const items = ci === 0 ? colItems[col] : barycentricSort(colItems[col], yCenters);
|
|
165
216
|
let y = FGCA_PAD + FGCA_HEADER_H + rowGap;
|
|
166
|
-
for (const item of
|
|
217
|
+
for (const item of items) {
|
|
167
218
|
const node: FGCAPreviewNode = { id: item.id, x, y, label: item.label, col };
|
|
168
219
|
nodes.push(node);
|
|
169
220
|
nodeMap.set(item.id, node);
|
|
221
|
+
yCenters.set(item.id, y + FGCA_NODE_H / 2);
|
|
170
222
|
y += FGCA_NODE_H + rowGap;
|
|
171
223
|
}
|
|
172
224
|
}
|
package/src/fgca/types.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface ActivityItem {
|
|
|
23
23
|
name: string;
|
|
24
24
|
goal_id?: number | string | null;
|
|
25
25
|
activity_type_id?: number;
|
|
26
|
+
/** Canonical values: initiative | programme | project | work_package. Unknown values pass through without error. */
|
|
27
|
+
type?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface ActivityTypeItem {
|
package/src/fgca/validate.ts
CHANGED
|
@@ -46,9 +46,17 @@ export function validateFGCADoc(input: unknown): FGCAValidationResult {
|
|
|
46
46
|
if (!Array.isArray(raw.factors)) errors.push({ code: 'SCHEMA_INVALID', message: 'factors must be an array', path: 'factors' });
|
|
47
47
|
if (!Array.isArray(raw.goals)) errors.push({ code: 'SCHEMA_INVALID', message: 'goals must be an array', path: 'goals' });
|
|
48
48
|
if (!Array.isArray(raw.changes)) errors.push({ code: 'SCHEMA_INVALID', message: 'changes must be an array', path: 'changes' });
|
|
49
|
-
|
|
49
|
+
// Accept canonical `actions:` or deprecated `activities:`; warn on the latter.
|
|
50
|
+
const hasActions = Array.isArray(raw.actions);
|
|
51
|
+
const hasActivities = Array.isArray(raw.activities);
|
|
52
|
+
if (!hasActions && !hasActivities) {
|
|
53
|
+
errors.push({ code: 'SCHEMA_INVALID', message: 'actions must be an array', path: 'actions' });
|
|
54
|
+
} else if ('activities' in raw) {
|
|
55
|
+
warnings.push({ code: 'DEPRECATED_NOTATION', message: '"activities" key is deprecated — rename to "actions"' });
|
|
56
|
+
}
|
|
50
57
|
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
51
58
|
|
|
59
|
+
const actionsArr = (hasActions ? raw.actions : raw.activities) as unknown[];
|
|
52
60
|
const doc = raw as unknown as FGCADoc;
|
|
53
61
|
const collectIds = (arr: unknown[]): Set<number | string> => {
|
|
54
62
|
const out = new Set<number | string>();
|
|
@@ -62,7 +70,7 @@ export function validateFGCADoc(input: unknown): FGCAValidationResult {
|
|
|
62
70
|
};
|
|
63
71
|
const factorIds = collectIds(doc.factors as unknown as unknown[]);
|
|
64
72
|
const goalIds = collectIds(doc.goals as unknown as unknown[]);
|
|
65
|
-
const activityIds = collectIds(
|
|
73
|
+
const activityIds = collectIds(actionsArr);
|
|
66
74
|
|
|
67
75
|
for (let i = 0; i < doc.factors.length; i++) {
|
|
68
76
|
const f = doc.factors[i] as unknown;
|
|
@@ -114,15 +122,15 @@ export function validateFGCADoc(input: unknown): FGCAValidationResult {
|
|
|
114
122
|
}
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
for (let i = 0; i <
|
|
118
|
-
const a =
|
|
125
|
+
for (let i = 0; i < actionsArr.length; i++) {
|
|
126
|
+
const a = actionsArr[i];
|
|
119
127
|
if (!a || typeof a !== 'object') {
|
|
120
|
-
errors.push({ code: 'SCHEMA_INVALID', message: '
|
|
128
|
+
errors.push({ code: 'SCHEMA_INVALID', message: 'action entry must be an object', path: `actions[${i}]` });
|
|
121
129
|
continue;
|
|
122
130
|
}
|
|
123
|
-
const
|
|
124
|
-
if (typeof
|
|
125
|
-
if (typeof
|
|
131
|
+
const action = a as { id?: unknown; name?: unknown };
|
|
132
|
+
if (typeof action.id !== 'number' && typeof action.id !== 'string') errors.push({ code: 'SCHEMA_INVALID', message: 'action id must be a number or string', path: `actions[${i}]` });
|
|
133
|
+
if (typeof action.name !== 'string' || !action.name.trim()) errors.push({ code: 'EMPTY_NAME', message: `Action ${String(action.id)} has empty name`, path: `actions[${i}]` });
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
return { valid: errors.length === 0, errors, warnings };
|
package/src/theme/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type { AdaptiveTokens } from './tokens.js';
|
|
2
|
-
export { BRAND, FUNCTIONAL, LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, STRUCTURAL, TYPOGRAPHY, CSS_VAR, getBaseResetCss } from './tokens.js';
|
|
2
|
+
export { BRAND, FUNCTIONAL, LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, TREE_LEVEL_COLORS, TREE_MATURITY_COLORS, STRUCTURAL, TYPOGRAPHY, CSS_VAR, getBaseResetCss } from './tokens.js';
|
|
3
3
|
export type { ThemeId } from './themes.js';
|
|
4
4
|
export { generateWebviewCss, generateSvgEmbedCss } from './themes.js';
|
package/src/theme/themes.ts
CHANGED
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
LAYER_COLORS,
|
|
3
3
|
LEVEL_COLORS,
|
|
4
4
|
MATURITY_COLORS,
|
|
5
|
+
TREE_LEVEL_COLORS,
|
|
6
|
+
TREE_MATURITY_COLORS,
|
|
5
7
|
STRUCTURAL,
|
|
6
8
|
TYPOGRAPHY,
|
|
7
9
|
CSS_VAR,
|
|
@@ -75,7 +77,19 @@ function diagramVars(variant: 'light' | 'dark' | 'hc'): string {
|
|
|
75
77
|
const sc = STRUCTURAL;
|
|
76
78
|
const lv = LEVEL_COLORS[variant];
|
|
77
79
|
const mat = MATURITY_COLORS[variant];
|
|
80
|
+
const treeLevel = TREE_LEVEL_COLORS;
|
|
81
|
+
const treeMat = TREE_MATURITY_COLORS[variant];
|
|
78
82
|
const levelVars = lv.map((c, i) => `--ts-level-${i}:${c}`).join(';') + ';';
|
|
83
|
+
const treeVars = [
|
|
84
|
+
`${CSS_VAR.treeLevel0}:${treeLevel.band0[variant]}`,
|
|
85
|
+
`${CSS_VAR.treeLevel1}:${treeLevel.band1[variant]}`,
|
|
86
|
+
`${CSS_VAR.treeLevel2}:${treeLevel.band2[variant]}`,
|
|
87
|
+
`${CSS_VAR.treeMaturity1}:${treeMat[0]}`,
|
|
88
|
+
`${CSS_VAR.treeMaturity2}:${treeMat[1]}`,
|
|
89
|
+
`${CSS_VAR.treeMaturity3}:${treeMat[2]}`,
|
|
90
|
+
`${CSS_VAR.treeMaturity4}:${treeMat[3]}`,
|
|
91
|
+
`${CSS_VAR.treeMaturity5}:${treeMat[4]}`,
|
|
92
|
+
].join(';') + ';';
|
|
79
93
|
return [
|
|
80
94
|
`${CSS_VAR.layerDriver}:${lc.driver[variant]}`,
|
|
81
95
|
`${CSS_VAR.layerFactor}:${lc.factor[variant]}`,
|
|
@@ -92,7 +106,7 @@ function diagramVars(variant: 'light' | 'dark' | 'hc'): string {
|
|
|
92
106
|
`${CSS_VAR.maturity3}:${mat[2]}`,
|
|
93
107
|
`${CSS_VAR.maturity4}:${mat[3]}`,
|
|
94
108
|
`${CSS_VAR.maturity5}:${mat[4]}`,
|
|
95
|
-
].join(';') + ';' + levelVars;
|
|
109
|
+
].join(';') + ';' + levelVars + treeVars;
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
/** CSS for the webview shell (toolbar, canvas). */
|
|
@@ -155,7 +169,18 @@ ${levelRules}
|
|
|
155
169
|
.compliance-deadline{fill:var(${cv.statusErrorBg});stroke:var(${cv.statusErrorFg});}
|
|
156
170
|
.compliance-deadline text{fill:var(${cv.statusErrorFg});}
|
|
157
171
|
.compliance-badge{fill:var(${cv.statusErrorFg});}
|
|
158
|
-
.compliance-badge-text{fill:var(${cv.textInverse});font-family:${t.fontFamily};font-size:8px;font-weight:700;}
|
|
172
|
+
.compliance-badge-text{fill:var(${cv.textInverse});font-family:${t.fontFamily};font-size:8px;font-weight:700;}
|
|
173
|
+
.tree-level-0{fill:var(${cv.treeLevel0});}
|
|
174
|
+
.tree-level-1{fill:var(${cv.treeLevel1});}
|
|
175
|
+
.tree-level-2{fill:var(${cv.treeLevel2});}
|
|
176
|
+
.tree-maturity-1{fill:var(${cv.treeMaturity1});}
|
|
177
|
+
.tree-maturity-2{fill:var(${cv.treeMaturity2});}
|
|
178
|
+
.tree-maturity-3{fill:var(${cv.treeMaturity3});}
|
|
179
|
+
.tree-maturity-4{fill:var(${cv.treeMaturity4});}
|
|
180
|
+
.tree-maturity-5{fill:var(${cv.treeMaturity5});}
|
|
181
|
+
.tree-collapse-btn{cursor:pointer;fill:white;stroke:var(${cv.nodeStroke});stroke-width:1.5;}
|
|
182
|
+
.tree-collapse-btn:hover{fill:var(${cv.bgElevated});}
|
|
183
|
+
.tree-collapse-lbl{fill:var(${cv.textSecondary});font-family:${t.fontFamily};font-size:12px;font-weight:700;dominant-baseline:central;pointer-events:none;}`;
|
|
159
184
|
}
|
|
160
185
|
|
|
161
186
|
/**
|
package/src/theme/tokens.ts
CHANGED
|
@@ -48,6 +48,28 @@ export const MATURITY_COLORS = {
|
|
|
48
48
|
hc: ['#f87171', '#fb923c', '#facc15', '#a3e635', '#4ade80'],
|
|
49
49
|
} as const;
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* DSM-matching capability tree node fill colors, grouped by depth band.
|
|
53
|
+
* band0: depth 0–2 (pink)
|
|
54
|
+
* band1: depth 3–4 (yellow)
|
|
55
|
+
* band2: depth 5+ (blue)
|
|
56
|
+
*/
|
|
57
|
+
export const TREE_LEVEL_COLORS = {
|
|
58
|
+
band0: { light: '#fee0e0', dark: '#3d0f0f', hc: '#ff9999' },
|
|
59
|
+
band1: { light: '#ffffcb', dark: '#3a3500', hc: '#ffff88' },
|
|
60
|
+
band2: { light: '#c2f0ff', dark: '#003d4d', hc: '#66ccff' },
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* DSM-matching maturity badge fills for the capability tree (L1–L5).
|
|
65
|
+
* Distinct from MATURITY_COLORS which drives the cards / compliance views.
|
|
66
|
+
*/
|
|
67
|
+
export const TREE_MATURITY_COLORS = {
|
|
68
|
+
light: ['#c1c1c1', '#ff3d67', '#ff9a59', '#fdef59', '#83cca3'],
|
|
69
|
+
dark: ['#555555', '#cc1040', '#d06020', '#c8c000', '#4d9970'],
|
|
70
|
+
hc: ['#cccccc', '#ff6699', '#ffbb77', '#ffff44', '#88ddaa'],
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
51
73
|
/**
|
|
52
74
|
* Typography hierarchy. All previews resolve text styling from these roles
|
|
53
75
|
* — never from inline font-* attributes on individual <text> elements.
|
|
@@ -128,6 +150,14 @@ export const CSS_VAR = {
|
|
|
128
150
|
maturity3: '--ts-maturity-3',
|
|
129
151
|
maturity4: '--ts-maturity-4',
|
|
130
152
|
maturity5: '--ts-maturity-5',
|
|
153
|
+
treeLevel0: '--ts-tree-level-0',
|
|
154
|
+
treeLevel1: '--ts-tree-level-1',
|
|
155
|
+
treeLevel2: '--ts-tree-level-2',
|
|
156
|
+
treeMaturity1: '--ts-tree-maturity-1',
|
|
157
|
+
treeMaturity2: '--ts-tree-maturity-2',
|
|
158
|
+
treeMaturity3: '--ts-tree-maturity-3',
|
|
159
|
+
treeMaturity4: '--ts-tree-maturity-4',
|
|
160
|
+
treeMaturity5: '--ts-tree-maturity-5',
|
|
131
161
|
} as const;
|
|
132
162
|
|
|
133
163
|
export function getBaseResetCss(): string {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* CROSS-DOCUMENT RESOLUTION CAVEAT
|
|
13
13
|
* The Activity Card is the only MULTI-DOCUMENT Studio notation: the card YAML
|
|
14
14
|
* names a project Activity, and the project's name, dates, motivation chain
|
|
15
|
-
* (Drivers →
|
|
15
|
+
* (Drivers → Goals → Changes) and child activities are pulled BY
|
|
16
16
|
* REFERENCE from the canonical element + relation store (`canon/elements/**`,
|
|
17
17
|
* `canon/relations/**`; see `../activity-card/resolver.ts`). That resolution
|
|
18
18
|
* requires reading those files from disk, which is NOT available inside the
|
|
@@ -135,10 +135,9 @@ export function renderActivityCardBody(layout: ActivityCardLayout, ox: number, o
|
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// Chain sections (Drivers →
|
|
138
|
+
// Chain sections (Drivers → Goals → Changes).
|
|
139
139
|
const SECTION_LEVEL: Record<string, number> = {
|
|
140
140
|
drivers: 4,
|
|
141
|
-
assessments: 5,
|
|
142
141
|
goals: 5,
|
|
143
142
|
changes: 6,
|
|
144
143
|
};
|
|
@@ -29,7 +29,7 @@ const COL_LABELS: Record<FGCAPreviewColumn, string> = {
|
|
|
29
29
|
driver: 'Drivers (D)',
|
|
30
30
|
goal: 'Goals (G)',
|
|
31
31
|
change: 'Changes (C)',
|
|
32
|
-
activity: '
|
|
32
|
+
activity: 'Actions (A)',
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
type FgcaLayout = ReturnType<typeof layoutFGCAPreview>;
|
|
@@ -90,7 +90,7 @@ export function renderFgcaBody(
|
|
|
90
90
|
`<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="8"/>`,
|
|
91
91
|
`<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>`,
|
|
92
92
|
twoLines
|
|
93
|
-
? `<text class="text-
|
|
93
|
+
? `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>`
|
|
94
94
|
: '',
|
|
95
95
|
`<text class="text-id" x="${n.x + NODE_W / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(entityId)}</text>`,
|
|
96
96
|
]
|
|
@@ -17,12 +17,11 @@ import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
|
|
|
17
17
|
import { escXml } from './render-util.js';
|
|
18
18
|
|
|
19
19
|
const NODE_W = 250;
|
|
20
|
-
const NODE_H =
|
|
20
|
+
const NODE_H = 72;
|
|
21
21
|
const RANK_SEP = 100;
|
|
22
22
|
const NODE_SEP = 24;
|
|
23
23
|
const PAD = 24;
|
|
24
|
-
const
|
|
25
|
-
const LABEL_TRIM = 34;
|
|
24
|
+
const LABEL_CHARS = 30;
|
|
26
25
|
|
|
27
26
|
export interface RenderGoalsOptions {
|
|
28
27
|
treeName?: string;
|
|
@@ -110,10 +109,32 @@ export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoal
|
|
|
110
109
|
const y = n.y + oy;
|
|
111
110
|
const level = n.data.level % 8;
|
|
112
111
|
const labelText = n.data.name ?? String(n.id);
|
|
113
|
-
const
|
|
112
|
+
const words = labelText.split(' ');
|
|
113
|
+
let line1 = '';
|
|
114
|
+
let line2 = '';
|
|
115
|
+
for (const w of words) {
|
|
116
|
+
if ((line1 + ' ' + w).trim().length <= LABEL_CHARS) {
|
|
117
|
+
line1 = (line1 + ' ' + w).trim();
|
|
118
|
+
} else if ((line2 + ' ' + w).trim().length <= LABEL_CHARS) {
|
|
119
|
+
line2 = (line2 + ' ' + w).trim();
|
|
120
|
+
} else if (!line2) {
|
|
121
|
+
line2 = w.slice(0, LABEL_CHARS - 2) + '…';
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const twoLines = line2.length > 0;
|
|
126
|
+
const nameY1 = twoLines ? y + 12 : y + 16;
|
|
127
|
+
const nameY2 = y + 26;
|
|
128
|
+
const typeY = twoLines ? y + 43 : y + 35;
|
|
129
|
+
const idY = twoLines ? y + 59 : y + 55;
|
|
130
|
+
const typeLabel = n.data.type ?? '';
|
|
131
|
+
const idText = String(n.data.id);
|
|
114
132
|
return `<g>
|
|
115
133
|
<rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="8"/>
|
|
116
|
-
<text class="text-primary" x="${x + n.width / 2}" y="${
|
|
134
|
+
<text class="text-primary" x="${x + n.width / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>${twoLines ? `
|
|
135
|
+
<text class="text-primary" x="${x + n.width / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : ''}${typeLabel ? `
|
|
136
|
+
<text class="text-secondary" x="${x + n.width / 2}" y="${typeY}" text-anchor="middle" dominant-baseline="central">${escXml(typeLabel)}</text>` : ''}
|
|
137
|
+
<text class="text-id" x="${x + n.width / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>
|
|
117
138
|
</g>`;
|
|
118
139
|
})
|
|
119
140
|
.join('\n');
|