@transitrix/diagrams 1.12.1 → 1.13.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/edge-path.d.ts +17 -0
- package/dist/edge-path.js +33 -4
- package/dist/fgca/index.d.ts +1 -1
- package/dist/fgca/index.js +1 -1
- package/dist/fgca/parse-canonical.d.ts +10 -2
- package/dist/fgca/parse-canonical.js +9 -4
- package/dist/fgca/preview-layout.d.ts +35 -4
- package/dist/fgca/preview-layout.js +214 -14
- package/dist/fgca/types.d.ts +4 -0
- package/dist/scope.d.ts +27 -0
- package/dist/scope.js +40 -0
- package/dist/theme/themes.js +3 -1
- package/dist/theme/tokens.d.ts +12 -0
- package/dist/theme/tokens.js +12 -0
- package/dist/webview/entry.js +8 -2
- package/dist/webview/render-fgca.d.ts +3 -0
- package/dist/webview/render-fgca.js +28 -5
- package/dist/webview/render-goals.d.ts +3 -0
- package/dist/webview/render-goals.js +10 -5
- package/package.json +1 -1
- package/src/edge-path.ts +45 -4
- package/src/fgca/index.ts +2 -0
- package/src/fgca/parse-canonical.ts +9 -2
- package/src/fgca/preview-layout.ts +255 -19
- package/src/fgca/types.ts +4 -0
- package/src/scope.ts +61 -0
- package/src/theme/themes.ts +3 -0
- package/src/theme/tokens.ts +13 -0
- package/src/webview/entry.ts +8 -2
- package/src/webview/render-fgca.ts +30 -4
- package/src/webview/render-goals.ts +11 -5
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
//
|
|
8
8
|
// No React, no DOM, no vscode. Safe to call in Node.js or a test environment.
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type { ChainColumnKey, ChainScope, FgcaScope } from '../scope.js';
|
|
11
|
+
import { isChainScope } from '../scope.js';
|
|
11
12
|
import { ENTITY_NODE_SIZE } from '../node-size-presets.js';
|
|
12
13
|
|
|
13
14
|
export type FGCAPreviewColumn = 'driver' | 'goal' | 'change' | 'activity';
|
|
@@ -32,6 +33,10 @@ export interface FGCAPreviewActivity {
|
|
|
32
33
|
id: number | string;
|
|
33
34
|
name: string;
|
|
34
35
|
goal_id?: number | string | null;
|
|
36
|
+
progress?: {
|
|
37
|
+
percent: number;
|
|
38
|
+
computedAt: string;
|
|
39
|
+
};
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/** Structural input the preview layout needs — a subset of the parsed DGCA/DGA doc. */
|
|
@@ -49,28 +54,227 @@ export interface FGCAPreviewLayoutOptions {
|
|
|
49
54
|
colGap?: number;
|
|
50
55
|
/** Vertical gap (px) between stacked nodes within a column. Default matches the historical hardcoded value. */
|
|
51
56
|
rowGap?: number;
|
|
52
|
-
/** Trim to a level cap
|
|
53
|
-
scope?:
|
|
57
|
+
/** Trim to a level cap, a single root goal, or per-column chain filters. Defaults to 'all'. */
|
|
58
|
+
scope?: FgcaScope;
|
|
54
59
|
/** Entity node width (px). Default {@link FGCA_NODE_W}. */
|
|
55
60
|
nodeWidth?: number;
|
|
56
61
|
/** Entity node height (px). Default {@link FGCA_NODE_H}. */
|
|
57
62
|
nodeHeight?: number;
|
|
58
63
|
}
|
|
59
64
|
|
|
65
|
+
function sid(id: number | string): string {
|
|
66
|
+
return String(id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ChainPath {
|
|
70
|
+
driverId?: string;
|
|
71
|
+
goalId?: string;
|
|
72
|
+
changeId?: string;
|
|
73
|
+
activityId?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every Driver–Goal–Change–Action thread in the document, plus a singleton
|
|
78
|
+
* path for any entity that is not on a connected thread (orphans). Used to
|
|
79
|
+
* AND column filters: a node stays visible when it appears on at least one
|
|
80
|
+
* path that includes every selected id.
|
|
81
|
+
*/
|
|
82
|
+
function enumerateChainPaths(doc: FGCAPreviewDoc): ChainPath[] {
|
|
83
|
+
const paths: ChainPath[] = [];
|
|
84
|
+
const seen = {
|
|
85
|
+
driver: new Set<string>(),
|
|
86
|
+
goal: new Set<string>(),
|
|
87
|
+
change: new Set<string>(),
|
|
88
|
+
activity: new Set<string>(),
|
|
89
|
+
};
|
|
90
|
+
const mark = (p: ChainPath): void => {
|
|
91
|
+
paths.push(p);
|
|
92
|
+
if (p.driverId) seen.driver.add(p.driverId);
|
|
93
|
+
if (p.goalId) seen.goal.add(p.goalId);
|
|
94
|
+
if (p.changeId) seen.change.add(p.changeId);
|
|
95
|
+
if (p.activityId) seen.activity.add(p.activityId);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const changes = doc.changes ?? [];
|
|
99
|
+
|
|
100
|
+
for (const g of doc.goals) {
|
|
101
|
+
const goalId = sid(g.id);
|
|
102
|
+
const driverIds = (g.factor ?? []).map(f => sid(f.id));
|
|
103
|
+
const drivers: Array<string | undefined> = driverIds.length > 0 ? driverIds : [undefined];
|
|
104
|
+
|
|
105
|
+
const goalChanges = changes.filter(c => sid(c.goal_id) === goalId);
|
|
106
|
+
const coveredActs = new Set(goalChanges.flatMap(c => c.activity_ids.map(sid)));
|
|
107
|
+
const directActs = doc.activities.filter(
|
|
108
|
+
a => a.goal_id != null && sid(a.goal_id) === goalId && !coveredActs.has(sid(a.id)),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const slots: Array<{ changeId?: string; activityIds: Array<string | undefined> }> = [];
|
|
112
|
+
for (const c of goalChanges) {
|
|
113
|
+
const acts = c.activity_ids.map(sid);
|
|
114
|
+
slots.push({ changeId: sid(c.id), activityIds: acts.length > 0 ? acts : [undefined] });
|
|
115
|
+
}
|
|
116
|
+
for (const a of directActs) {
|
|
117
|
+
slots.push({ changeId: undefined, activityIds: [sid(a.id)] });
|
|
118
|
+
}
|
|
119
|
+
if (slots.length === 0) {
|
|
120
|
+
slots.push({ changeId: undefined, activityIds: [undefined] });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const d of drivers) {
|
|
124
|
+
for (const slot of slots) {
|
|
125
|
+
for (const a of slot.activityIds) {
|
|
126
|
+
mark({ driverId: d, goalId, changeId: slot.changeId, activityId: a });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const f of doc.factors) {
|
|
133
|
+
const id = sid(f.id);
|
|
134
|
+
if (!seen.driver.has(id)) mark({ driverId: id });
|
|
135
|
+
}
|
|
136
|
+
for (const g of doc.goals) {
|
|
137
|
+
const id = sid(g.id);
|
|
138
|
+
if (!seen.goal.has(id)) mark({ goalId: id });
|
|
139
|
+
}
|
|
140
|
+
for (const c of changes) {
|
|
141
|
+
const id = sid(c.id);
|
|
142
|
+
if (!seen.change.has(id)) mark({ changeId: id, goalId: sid(c.goal_id) });
|
|
143
|
+
}
|
|
144
|
+
for (const a of doc.activities) {
|
|
145
|
+
const id = sid(a.id);
|
|
146
|
+
if (!seen.activity.has(id)) {
|
|
147
|
+
mark({ activityId: id, goalId: a.goal_id != null ? sid(a.goal_id) : undefined });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return paths;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function selectChainScopedFGCA(
|
|
155
|
+
doc: FGCAPreviewDoc,
|
|
156
|
+
scope: ChainScope,
|
|
157
|
+
hideChanges: boolean,
|
|
158
|
+
): FGCAPreviewDoc {
|
|
159
|
+
const driverId = scope.driverId || undefined;
|
|
160
|
+
const goalId = scope.goalId || undefined;
|
|
161
|
+
const changeId = hideChanges ? undefined : (scope.changeId || undefined);
|
|
162
|
+
const activityId = scope.activityId || undefined;
|
|
163
|
+
if (!driverId && !goalId && !changeId && !activityId) return doc;
|
|
164
|
+
|
|
165
|
+
const matched = enumerateChainPaths(doc).filter(p => {
|
|
166
|
+
if (driverId && p.driverId !== driverId) return false;
|
|
167
|
+
if (goalId && p.goalId !== goalId) return false;
|
|
168
|
+
if (changeId && p.changeId !== changeId) return false;
|
|
169
|
+
if (activityId && p.activityId !== activityId) return false;
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const driverIds = new Set<string>();
|
|
174
|
+
const goalIds = new Set<string>();
|
|
175
|
+
const changeIds = new Set<string>();
|
|
176
|
+
const activityIds = new Set<string>();
|
|
177
|
+
for (const p of matched) {
|
|
178
|
+
if (p.driverId) driverIds.add(p.driverId);
|
|
179
|
+
if (p.goalId) goalIds.add(p.goalId);
|
|
180
|
+
if (p.changeId) changeIds.add(p.changeId);
|
|
181
|
+
if (p.activityId) activityIds.add(p.activityId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
factors: doc.factors.filter(f => driverIds.has(sid(f.id))),
|
|
186
|
+
goals: doc.goals.filter(g => goalIds.has(sid(g.id))),
|
|
187
|
+
changes: doc.changes === undefined ? undefined : doc.changes.filter(c => changeIds.has(sid(c.id))),
|
|
188
|
+
activities: doc.activities.filter(a => activityIds.has(sid(a.id))),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const CHAIN_COLUMN_KEYS: ChainColumnKey[] = ['driverId', 'goalId', 'changeId', 'activityId'];
|
|
193
|
+
|
|
194
|
+
function columnEntities(
|
|
195
|
+
doc: FGCAPreviewDoc,
|
|
196
|
+
column: ChainColumnKey,
|
|
197
|
+
): Array<{ id: string; name: string }> {
|
|
198
|
+
if (column === 'driverId') return doc.factors.map(f => ({ id: sid(f.id), name: f.name ?? '' }));
|
|
199
|
+
if (column === 'goalId') return doc.goals.map(g => ({ id: sid(g.id), name: g.name ?? '' }));
|
|
200
|
+
if (column === 'changeId') return (doc.changes ?? []).map(c => ({ id: sid(c.id), name: c.name ?? '' }));
|
|
201
|
+
return doc.activities.map(a => ({ id: sid(a.id), name: a.name ?? '' }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Dropdown options for one chain column, cascaded from the other selected
|
|
206
|
+
* filters (this column's own filter is ignored so the user can still switch it).
|
|
207
|
+
*/
|
|
208
|
+
export function chainColumnOptions(
|
|
209
|
+
doc: FGCAPreviewDoc,
|
|
210
|
+
scope: ChainScope,
|
|
211
|
+
column: ChainColumnKey,
|
|
212
|
+
hideChanges = false,
|
|
213
|
+
): Array<{ id: string; name: string }> {
|
|
214
|
+
if (hideChanges && column === 'changeId') return [];
|
|
215
|
+
const withoutSelf: ChainScope = { ...scope, mode: 'chain', [column]: undefined };
|
|
216
|
+
const filtered = selectChainScopedFGCA(doc, withoutSelf, hideChanges);
|
|
217
|
+
return columnEntities(filtered, column);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Drop column filters that are no longer reachable given the others.
|
|
222
|
+
* `justChanged` is kept even when incompatible — the last pick wins, neighbours yield.
|
|
223
|
+
*/
|
|
224
|
+
export function sanitizeChainScope(
|
|
225
|
+
doc: FGCAPreviewDoc,
|
|
226
|
+
scope: ChainScope,
|
|
227
|
+
opts: { justChanged?: ChainColumnKey; hideChanges?: boolean } = {},
|
|
228
|
+
): ChainScope {
|
|
229
|
+
const next: ChainScope = {
|
|
230
|
+
mode: 'chain',
|
|
231
|
+
driverId: scope.driverId || undefined,
|
|
232
|
+
goalId: scope.goalId || undefined,
|
|
233
|
+
changeId: opts.hideChanges ? undefined : (scope.changeId || undefined),
|
|
234
|
+
activityId: scope.activityId || undefined,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
let changed = true;
|
|
238
|
+
while (changed) {
|
|
239
|
+
changed = false;
|
|
240
|
+
for (const key of CHAIN_COLUMN_KEYS) {
|
|
241
|
+
if (key === opts.justChanged) continue;
|
|
242
|
+
if (opts.hideChanges && key === 'changeId') continue;
|
|
243
|
+
const id = next[key];
|
|
244
|
+
if (!id) continue;
|
|
245
|
+
const available = new Set(chainColumnOptions(doc, next, key, opts.hideChanges).map(o => o.id));
|
|
246
|
+
if (!available.has(id)) {
|
|
247
|
+
next[key] = undefined;
|
|
248
|
+
changed = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return next;
|
|
253
|
+
}
|
|
254
|
+
|
|
60
255
|
/**
|
|
61
256
|
* Trims a DGCA/DGA doc to a scope.
|
|
62
257
|
*
|
|
63
258
|
* DGCA/DGA goals are flat (no parent_id), so:
|
|
64
259
|
* - 'level' → goals with `(level ?? 0) <= maxLevel`.
|
|
65
260
|
* - 'root' → the single goal whose id matches `rootGoalId` (empty when absent).
|
|
261
|
+
* - 'chain' → AND of per-column ids (Driver / Goal / Change / Action); empty
|
|
262
|
+
* ids are All. DGA passes hideChanges so a Change filter is ignored.
|
|
66
263
|
*
|
|
67
264
|
* Factors, changes and activities are then kept only when they touch a visible
|
|
68
265
|
* goal: a factor referenced by a visible goal, a change whose `goal_id` is
|
|
69
266
|
* visible, an activity bound to a visible goal directly or via a visible
|
|
70
267
|
* change. Pure and exported so an access-control layer can reuse it.
|
|
71
268
|
*/
|
|
72
|
-
export function selectScopedFGCA(
|
|
269
|
+
export function selectScopedFGCA(
|
|
270
|
+
doc: FGCAPreviewDoc,
|
|
271
|
+
scope: FgcaScope,
|
|
272
|
+
opts: { hideChanges?: boolean } = {},
|
|
273
|
+
): FGCAPreviewDoc {
|
|
73
274
|
if (scope.mode === 'all') return doc;
|
|
275
|
+
if (isChainScope(scope)) {
|
|
276
|
+
return selectChainScopedFGCA(doc, scope, opts.hideChanges === true);
|
|
277
|
+
}
|
|
74
278
|
|
|
75
279
|
const visibleGoals =
|
|
76
280
|
scope.mode === 'level'
|
|
@@ -104,6 +308,13 @@ export interface FGCAPreviewNode {
|
|
|
104
308
|
y: number;
|
|
105
309
|
label: string;
|
|
106
310
|
col: FGCAPreviewColumn;
|
|
311
|
+
type?: string;
|
|
312
|
+
progress?: {
|
|
313
|
+
percent: number;
|
|
314
|
+
computedAt: string;
|
|
315
|
+
};
|
|
316
|
+
/** Marks a synthesized node (e.g. virtual Change) vs. a real entity. */
|
|
317
|
+
virtual?: boolean;
|
|
107
318
|
}
|
|
108
319
|
export interface FGCAPreviewEdge {
|
|
109
320
|
sx: number;
|
|
@@ -147,18 +358,33 @@ export function layoutFGCAPreview(
|
|
|
147
358
|
} = options;
|
|
148
359
|
|
|
149
360
|
// Trim to scope first; everything below lays out the visible subset only.
|
|
150
|
-
const doc = selectScopedFGCA(inputDoc, scope);
|
|
361
|
+
const doc = selectScopedFGCA(inputDoc, scope, { hideChanges });
|
|
151
362
|
|
|
152
363
|
const colStride = nodeWidth + colGap;
|
|
153
364
|
const cols: FGCAPreviewColumn[] = hideChanges
|
|
154
365
|
? ['driver', 'goal', 'activity']
|
|
155
366
|
: ['driver', 'goal', 'change', 'activity'];
|
|
156
367
|
const changes = doc.changes ?? [];
|
|
157
|
-
const
|
|
368
|
+
const coveredActivities = new Set(changes.flatMap(c => c.activity_ids.map(String)));
|
|
369
|
+
|
|
370
|
+
// Synthesize virtual Change nodes for activities without a real Change.
|
|
371
|
+
// Each virtual node is 1:1 with one activity (not shared across activities).
|
|
372
|
+
const virtualChanges: Array<{ id: string; activityId: string; goalId: string }> = [];
|
|
373
|
+
for (const a of doc.activities) {
|
|
374
|
+
if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
|
|
375
|
+
const virtualId = `vchange_activity_${a.id}`;
|
|
376
|
+
virtualChanges.push({ id: virtualId, activityId: String(a.id), goalId: String(a.goal_id) });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const colItems: Record<FGCAPreviewColumn, Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }>> = {
|
|
158
381
|
driver: doc.factors.map(f => ({ id: `driver_${f.id}`, label: f.name })),
|
|
159
382
|
goal: doc.goals.map(g => ({ id: `goal_${g.id}`, label: g.name })),
|
|
160
|
-
change:
|
|
161
|
-
|
|
383
|
+
change: [
|
|
384
|
+
...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
|
|
385
|
+
...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
|
|
386
|
+
],
|
|
387
|
+
activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress })),
|
|
162
388
|
};
|
|
163
389
|
|
|
164
390
|
// Build predecessor map: for each node, which node IDs in the previous column
|
|
@@ -171,12 +397,17 @@ export function layoutFGCAPreview(
|
|
|
171
397
|
for (const c of changes) {
|
|
172
398
|
predecessors.set(`change_${c.id}`, [`goal_${c.goal_id}`]);
|
|
173
399
|
}
|
|
174
|
-
const
|
|
400
|
+
for (const v of virtualChanges) {
|
|
401
|
+
predecessors.set(v.id, [`goal_${v.goalId}`]);
|
|
402
|
+
}
|
|
175
403
|
for (const a of doc.activities) {
|
|
176
404
|
const preds = changes
|
|
177
405
|
.filter(c => c.activity_ids.map(String).includes(String(a.id)))
|
|
178
406
|
.map(c => `change_${c.id}`);
|
|
179
|
-
|
|
407
|
+
const virtualChange = virtualChanges.find(v => v.activityId === String(a.id));
|
|
408
|
+
if (virtualChange) {
|
|
409
|
+
preds.push(virtualChange.id);
|
|
410
|
+
}
|
|
180
411
|
predecessors.set(`activity_${a.id}`, preds);
|
|
181
412
|
}
|
|
182
413
|
} else {
|
|
@@ -198,9 +429,9 @@ export function layoutFGCAPreview(
|
|
|
198
429
|
// Nodes with no predecessors sort last (Infinity barycenter) so they don't
|
|
199
430
|
// displace connected nodes.
|
|
200
431
|
function barycentricSort(
|
|
201
|
-
items: Array<{ id: string; label: string }>,
|
|
432
|
+
items: Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }>,
|
|
202
433
|
yCenters: Map<string, number>,
|
|
203
|
-
): Array<{ id: string; label: string }> {
|
|
434
|
+
): Array<{ id: string; label: string; type?: string; progress?: { percent: number; computedAt: string }; virtual?: boolean }> {
|
|
204
435
|
return [...items].sort((a, b) => {
|
|
205
436
|
const pA = (predecessors.get(a.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
|
|
206
437
|
const pB = (predecessors.get(b.id) ?? []).map(p => yCenters.get(p) ?? 0).filter(v => v > 0);
|
|
@@ -222,7 +453,16 @@ export function layoutFGCAPreview(
|
|
|
222
453
|
const items = ci === 0 ? colItems[col] : barycentricSort(colItems[col], yCenters);
|
|
223
454
|
let y = FGCA_PAD + FGCA_HEADER_H + rowGap;
|
|
224
455
|
for (const item of items) {
|
|
225
|
-
const node: FGCAPreviewNode = {
|
|
456
|
+
const node: FGCAPreviewNode = {
|
|
457
|
+
id: item.id,
|
|
458
|
+
x,
|
|
459
|
+
y,
|
|
460
|
+
label: item.label,
|
|
461
|
+
col,
|
|
462
|
+
type: item.type,
|
|
463
|
+
progress: item.progress,
|
|
464
|
+
virtual: item.virtual,
|
|
465
|
+
};
|
|
226
466
|
nodes.push(node);
|
|
227
467
|
nodeMap.set(item.id, node);
|
|
228
468
|
yCenters.set(item.id, y + nodeHeight / 2);
|
|
@@ -257,12 +497,8 @@ export function layoutFGCAPreview(
|
|
|
257
497
|
} else {
|
|
258
498
|
for (const c of changes) addEdge(`goal_${c.goal_id}`, `change_${c.id}`);
|
|
259
499
|
for (const c of changes) for (const aid of c.activity_ids) addEdge(`change_${c.id}`, `activity_${aid}`);
|
|
260
|
-
const
|
|
261
|
-
for (const
|
|
262
|
-
if (a.goal_id != null && !coveredActivities.has(a.id)) {
|
|
263
|
-
addEdge(`goal_${a.goal_id}`, `activity_${a.id}`);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
500
|
+
for (const v of virtualChanges) addEdge(`goal_${v.goalId}`, v.id);
|
|
501
|
+
for (const v of virtualChanges) addEdge(v.id, `activity_${v.activityId}`);
|
|
266
502
|
}
|
|
267
503
|
|
|
268
504
|
const maxNodeBottom = nodes.reduce(
|
package/src/fgca/types.ts
CHANGED
|
@@ -25,6 +25,10 @@ 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
|
+
progress?: {
|
|
29
|
+
percent: number;
|
|
30
|
+
computedAt: string;
|
|
31
|
+
};
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export interface ActivityTypeItem {
|
package/src/scope.ts
CHANGED
|
@@ -20,12 +20,38 @@ export type Scope =
|
|
|
20
20
|
| { mode: 'level'; maxLevel: number }
|
|
21
21
|
| { mode: 'root'; rootGoalId: string };
|
|
22
22
|
|
|
23
|
+
/** Column filters on a DGCA/DGA chain. Empty / omitted id = All for that column. */
|
|
24
|
+
export type ChainColumnKey = 'driverId' | 'goalId' | 'changeId' | 'activityId';
|
|
25
|
+
|
|
26
|
+
export type ChainScope = {
|
|
27
|
+
mode: 'chain';
|
|
28
|
+
driverId?: string;
|
|
29
|
+
goalId?: string;
|
|
30
|
+
changeId?: string;
|
|
31
|
+
activityId?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Scope accepted by DGCA/DGA layout — Goals tree still uses {@link Scope} only. */
|
|
35
|
+
export type FgcaScope = Scope | ChainScope;
|
|
36
|
+
|
|
23
37
|
/** Default scope — render everything. */
|
|
24
38
|
export const SCOPE_ALL: Scope = { mode: 'all' };
|
|
25
39
|
|
|
40
|
+
export function isChainScope(scope: FgcaScope): scope is ChainScope {
|
|
41
|
+
return scope.mode === 'chain';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when at least one column filter is set. */
|
|
45
|
+
export function chainScopeActive(scope: ChainScope): boolean {
|
|
46
|
+
return !!(scope.driverId || scope.goalId || scope.changeId || scope.activityId);
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
/** Warning code emitted when a root-mode scope names a goal that isn't in the document. */
|
|
27
50
|
export const SCOPE_MISSING_ROOT_CODE = 'SCOPE-001';
|
|
28
51
|
|
|
52
|
+
/** Warning code emitted when a chain-column filter names an id that isn't in the document. */
|
|
53
|
+
export const SCOPE_MISSING_CHAIN_CODE = 'SCOPE-002';
|
|
54
|
+
|
|
29
55
|
/**
|
|
30
56
|
* Returns the SCOPE-001 warning when `scope` is root-mode and `rootGoalId` is
|
|
31
57
|
* not among the document's goal ids; otherwise null. Goal ids are compared as
|
|
@@ -44,3 +70,38 @@ export function checkScopeRoot(scope: Scope, goalIds: Iterable<string | number>)
|
|
|
44
70
|
message: `Scope root goal "${scope.rootGoalId}" was not found in this document — nothing to show. Clear the scope or pick a goal that exists.`,
|
|
45
71
|
};
|
|
46
72
|
}
|
|
73
|
+
|
|
74
|
+
function idSetHas(ids: Iterable<string | number>, want: string): boolean {
|
|
75
|
+
for (const id of ids) {
|
|
76
|
+
if (String(id) === want) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns a SCOPE-002 warning when a chain filter names an id that is not in
|
|
83
|
+
* the document's corresponding column; otherwise null. Several missing ids
|
|
84
|
+
* are listed in one message.
|
|
85
|
+
*/
|
|
86
|
+
export function checkChainScope(
|
|
87
|
+
scope: ChainScope,
|
|
88
|
+
ids: {
|
|
89
|
+
drivers: Iterable<string | number>;
|
|
90
|
+
goals: Iterable<string | number>;
|
|
91
|
+
changes: Iterable<string | number>;
|
|
92
|
+
activities: Iterable<string | number>;
|
|
93
|
+
},
|
|
94
|
+
hideChanges = false,
|
|
95
|
+
): ValidationWarning | null {
|
|
96
|
+
if (scope.mode !== 'chain') return null;
|
|
97
|
+
const missing: string[] = [];
|
|
98
|
+
if (scope.driverId && !idSetHas(ids.drivers, scope.driverId)) missing.push(`driver "${scope.driverId}"`);
|
|
99
|
+
if (scope.goalId && !idSetHas(ids.goals, scope.goalId)) missing.push(`goal "${scope.goalId}"`);
|
|
100
|
+
if (!hideChanges && scope.changeId && !idSetHas(ids.changes, scope.changeId)) missing.push(`change "${scope.changeId}"`);
|
|
101
|
+
if (scope.activityId && !idSetHas(ids.activities, scope.activityId)) missing.push(`action "${scope.activityId}"`);
|
|
102
|
+
if (missing.length === 0) return null;
|
|
103
|
+
return {
|
|
104
|
+
code: SCOPE_MISSING_CHAIN_CODE,
|
|
105
|
+
message: `Scope ${missing.join(', ')} was not found in this document — nothing to show for that filter. Clear the scope or pick an id that exists.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/theme/themes.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
MATURITY_COLORS,
|
|
5
5
|
TREE_LEVEL_COLORS,
|
|
6
6
|
TREE_MATURITY_COLORS,
|
|
7
|
+
VIRTUAL_ENTITY,
|
|
7
8
|
STRUCTURAL,
|
|
8
9
|
BRAND_EMPHASIS,
|
|
9
10
|
TYPOGRAPHY,
|
|
@@ -97,6 +98,7 @@ function diagramVars(variant: 'light' | 'dark' | 'hc'): string {
|
|
|
97
98
|
`${CSS_VAR.layerGoal}:${lc.goal[variant]}`,
|
|
98
99
|
`${CSS_VAR.layerChange}:${lc.change[variant]}`,
|
|
99
100
|
`${CSS_VAR.layerActivity}:${lc.activity[variant]}`,
|
|
101
|
+
`${CSS_VAR.virtualEntity}:${VIRTUAL_ENTITY[variant]}`,
|
|
100
102
|
`${CSS_VAR.nodeStroke}:${sc.nodeStroke[variant]}`,
|
|
101
103
|
`${CSS_VAR.edgeStroke}:${sc.edgeStroke[variant]}`,
|
|
102
104
|
`${CSS_VAR.textPrimary}:${sc.textPrimary[variant]}`,
|
|
@@ -155,6 +157,7 @@ function diagramClassCss(): string {
|
|
|
155
157
|
.layer-goal{fill:var(${cv.layerGoal});}
|
|
156
158
|
.layer-change{fill:var(${cv.layerChange});}
|
|
157
159
|
.layer-activity{fill:var(${cv.layerActivity});}
|
|
160
|
+
.layer-virtual{fill:var(${cv.virtualEntity});stroke-dasharray:4,3;}
|
|
158
161
|
${levelRules}
|
|
159
162
|
.diagram-edge{stroke:var(${cv.edgeStroke});stroke-width:1.5;fill:none;}
|
|
160
163
|
.arrow-fill{fill:var(${cv.edgeStroke});}
|
package/src/theme/tokens.ts
CHANGED
|
@@ -42,6 +42,18 @@ export const LAYER_COLORS = {
|
|
|
42
42
|
activity: { light: '#b4e0ee', dark: '#1a414e', hc: '#4ade80' },
|
|
43
43
|
} as const;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Virtual/synthesized entity fill — used for placeholder nodes that represent
|
|
47
|
+
* absence (e.g. a synthesized Change box when an Action skips straight to its Goal).
|
|
48
|
+
* Pale neutral tint, distinctly muted from real entity colors; border dashed to
|
|
49
|
+
* signal non-reality.
|
|
50
|
+
*/
|
|
51
|
+
export const VIRTUAL_ENTITY = {
|
|
52
|
+
light: '#f3f4f6',
|
|
53
|
+
dark: '#2a3134',
|
|
54
|
+
hc: '#e2e8f0',
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
45
57
|
/**
|
|
46
58
|
* Level fill colors for Goals tree nodes — 8 slots, cycled by level index.
|
|
47
59
|
* A single-hue petrol tint ramp (level-0 lightest/root → level-7 deepest);
|
|
@@ -188,6 +200,7 @@ export const CSS_VAR = {
|
|
|
188
200
|
layerGoal: '--ts-layer-goal',
|
|
189
201
|
layerChange: '--ts-layer-change',
|
|
190
202
|
layerActivity: '--ts-layer-activity',
|
|
203
|
+
virtualEntity: '--ts-virtual-entity',
|
|
191
204
|
nodeStroke: '--ts-node-stroke',
|
|
192
205
|
edgeStroke: '--ts-edge-stroke',
|
|
193
206
|
textPrimary: '--ts-text-primary',
|
package/src/webview/entry.ts
CHANGED
|
@@ -198,7 +198,10 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
198
198
|
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
199
199
|
? new Set(meta['__outOfScopeFactorIds'] as string[])
|
|
200
200
|
: undefined;
|
|
201
|
-
const
|
|
201
|
+
const progressData = meta['__progressData'] instanceof Map
|
|
202
|
+
? (meta['__progressData'] as Map<string, { percent: number; computedAt: string }>)
|
|
203
|
+
: undefined;
|
|
204
|
+
const v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
202
205
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
203
206
|
r.errors.push(...v.errors);
|
|
204
207
|
r.warnings.push(...v.warnings);
|
|
@@ -219,7 +222,10 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
219
222
|
: Array.isArray(meta['__outOfScopeFactorIds'])
|
|
220
223
|
? new Set(meta['__outOfScopeFactorIds'] as string[])
|
|
221
224
|
: undefined;
|
|
222
|
-
const
|
|
225
|
+
const progressData = meta['__progressData'] instanceof Map
|
|
226
|
+
? (meta['__progressData'] as Map<string, { percent: number; computedAt: string }>)
|
|
227
|
+
: undefined;
|
|
228
|
+
const v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
223
229
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
224
230
|
r.errors.push(...v.errors);
|
|
225
231
|
r.warnings.push(...v.warnings);
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type FGCAPreviewColumn,
|
|
22
22
|
} from '../fgca/preview-layout.js';
|
|
23
23
|
import type { FGCADoc } from '../fgca/validate.js';
|
|
24
|
-
import {
|
|
24
|
+
import { previewEdgePath, DEFAULT_EDGE_CURVATURE, type EdgeStyle } from '../edge-path.js';
|
|
25
25
|
import { parseNodeSizePreset, resolveDgcaNodeSize, type NodeSizePreset } from '../node-size-presets.js';
|
|
26
26
|
import { generateSvgEmbedCss } from '../theme/index.js';
|
|
27
27
|
import { emitCenteredTextSvg, layoutCenteredEntityText, truncateLine } from './entity-text-layout.js';
|
|
@@ -39,6 +39,7 @@ type FgcaLayout = ReturnType<typeof layoutFGCAPreview>;
|
|
|
39
39
|
export interface RenderFgcaBodyOptions {
|
|
40
40
|
nodeWidth?: number;
|
|
41
41
|
nodeHeight?: number;
|
|
42
|
+
edgeStyle?: EdgeStyle;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -60,7 +61,9 @@ export function renderFgcaBody(
|
|
|
60
61
|
): string {
|
|
61
62
|
const nodeWidth = bodyOptions.nodeWidth ?? FGCA_NODE_W;
|
|
62
63
|
const nodeHeight = bodyOptions.nodeHeight ?? FGCA_NODE_H;
|
|
64
|
+
const edgeStyle = bodyOptions.edgeStyle;
|
|
63
65
|
const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
|
|
66
|
+
const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
64
67
|
|
|
65
68
|
const headerSvg = columns
|
|
66
69
|
.map(({ col, x }) =>
|
|
@@ -71,13 +74,14 @@ export function renderFgcaBody(
|
|
|
71
74
|
const edgeSvg = edges
|
|
72
75
|
.map(
|
|
73
76
|
(e) =>
|
|
74
|
-
`<path d="${
|
|
77
|
+
`<path d="${previewEdgePath(e.sx, e.sy, e.tx, e.ty, edgeStyle, curvature, entryCurvature)}" class="diagram-edge" marker-end="url(#arrow)"/>`,
|
|
75
78
|
)
|
|
76
79
|
.join('\n');
|
|
77
80
|
|
|
78
81
|
const nodeSvg = nodes
|
|
79
82
|
.map((n) => {
|
|
80
83
|
const entityId = n.id.slice(n.id.indexOf('_') + 1);
|
|
84
|
+
const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
|
|
81
85
|
const specs = layoutCenteredEntityText({
|
|
82
86
|
boxX: n.x,
|
|
83
87
|
boxY: n.y,
|
|
@@ -89,10 +93,29 @@ export function renderFgcaBody(
|
|
|
89
93
|
idMaxLines: 1,
|
|
90
94
|
});
|
|
91
95
|
const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
|
|
96
|
+
const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
|
|
97
|
+
const typeBadge = n.type && n.col === 'activity'
|
|
98
|
+
? `<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
|
+
: '';
|
|
100
|
+
const progressBadge = n.progress && n.col === 'activity'
|
|
101
|
+
? (() => {
|
|
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>`;
|
|
108
|
+
})()
|
|
109
|
+
: '';
|
|
92
110
|
return [
|
|
93
|
-
`<
|
|
111
|
+
`<g>`,
|
|
112
|
+
`<title>${titleContent}</title>`,
|
|
113
|
+
`<rect class="${nodeClass}" x="${n.x}" y="${n.y}" width="${nodeWidth}" height="${nodeHeight}" rx="8"/>`,
|
|
94
114
|
textSvg,
|
|
95
|
-
|
|
115
|
+
typeBadge,
|
|
116
|
+
progressBadge,
|
|
117
|
+
`</g>`,
|
|
118
|
+
].filter(Boolean).join('\n');
|
|
96
119
|
})
|
|
97
120
|
.join('\n');
|
|
98
121
|
|
|
@@ -108,6 +131,7 @@ export interface RenderFgcaOptions {
|
|
|
108
131
|
curvature?: number;
|
|
109
132
|
/** Entry curvature at the target node; defaults to `curvature` when omitted. */
|
|
110
133
|
entryCurvature?: number;
|
|
134
|
+
edgeStyle?: EdgeStyle;
|
|
111
135
|
nodeSizePreset?: NodeSizePreset;
|
|
112
136
|
layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
|
|
113
137
|
/** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
|
|
@@ -120,6 +144,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
120
144
|
title = '',
|
|
121
145
|
curvature = DEFAULT_EDGE_CURVATURE,
|
|
122
146
|
entryCurvature,
|
|
147
|
+
edgeStyle,
|
|
123
148
|
nodeSizePreset = 'normal',
|
|
124
149
|
layoutOptions,
|
|
125
150
|
scopeCaption = false,
|
|
@@ -141,6 +166,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
141
166
|
const body = renderFgcaBody(columns, nodes, edges, curvature, entryCurvature, {
|
|
142
167
|
nodeWidth: nodeSize.width,
|
|
143
168
|
nodeHeight: nodeSize.height,
|
|
169
|
+
edgeStyle,
|
|
144
170
|
});
|
|
145
171
|
|
|
146
172
|
const titleSvg = title
|