@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
package/dist/edge-path.d.ts
CHANGED
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
export declare const EDGE_MIN_HANDLE = 64;
|
|
7
7
|
/** Multiplier applied to the base handle length. 1 = historical appearance. */
|
|
8
8
|
export declare const DEFAULT_EDGE_CURVATURE = 1;
|
|
9
|
+
/** Path shape for Goals / DGCA / DGA preview edges. Default matches today's cubic. */
|
|
10
|
+
export type EdgeStyle = 'straight' | 'bezier' | 'polyline';
|
|
11
|
+
export declare const DEFAULT_EDGE_STYLE: EdgeStyle;
|
|
12
|
+
export declare function parseEdgeStyle(v: unknown): EdgeStyle;
|
|
13
|
+
/** Straight segment — the arrowhead follows the chord, not the node edge. */
|
|
14
|
+
export declare function straightEdgePath(sx: number, sy: number, tx: number, ty: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* Orthogonal (Manhattan) polyline: horizontal out, vertical, horizontal in.
|
|
17
|
+
* The last segment is horizontal so `marker-end` still meets the node's
|
|
18
|
+
* vertical edge the same way the cubic does.
|
|
19
|
+
*/
|
|
20
|
+
export declare function orthogonalEdgePath(sx: number, sy: number, tx: number, ty: number): string;
|
|
9
21
|
/**
|
|
10
22
|
* Builds the SVG `d` for a horizontal-tangent cubic Bézier from (sx,sy) to
|
|
11
23
|
* (tx,ty).
|
|
@@ -35,3 +47,8 @@ export declare function horizontalCubicEdgePath(sx: number, sy: number, tx: numb
|
|
|
35
47
|
* it — see `render-activities.ts`'s `blockingNodes`/`bowY` for the margin.
|
|
36
48
|
*/
|
|
37
49
|
export declare function bowedCubicEdgePath(sx: number, sy: number, tx: number, ty: number, bowY: number, curvature?: number): string;
|
|
50
|
+
/**
|
|
51
|
+
* Picks the SVG `d` for a Goals / DGCA / DGA preview edge.
|
|
52
|
+
* Unknown styles fall back to the historical cubic.
|
|
53
|
+
*/
|
|
54
|
+
export declare function previewEdgePath(sx: number, sy: number, tx: number, ty: number, style?: EdgeStyle, curvature?: number, entryCurvature?: number): string;
|
package/dist/edge-path.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Shared edge geometry for the static notation previews (Goals, FGCA, FGA,
|
|
2
|
-
// Activities).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// arrow reads as perpendicular to the node's vertical edge.
|
|
2
|
+
// Activities). The default path is a cubic Bézier with horizontal control
|
|
3
|
+
// handles so the marker-end arrow sits flush on the node's vertical edge.
|
|
4
|
+
// Goals / DGCA / DGA can switch to a straight chord or an orthogonal polyline.
|
|
6
5
|
//
|
|
7
6
|
// Pulling the path math here (rather than duplicating it inline in each
|
|
8
7
|
// preview) makes the configurable-curvature behaviour
|
|
@@ -26,6 +25,25 @@ const DY_FACTOR = 0.8;
|
|
|
26
25
|
const MAX_HANDLE = 160;
|
|
27
26
|
/** Multiplier applied to the base handle length. 1 = historical appearance. */
|
|
28
27
|
export const DEFAULT_EDGE_CURVATURE = 1;
|
|
28
|
+
export const DEFAULT_EDGE_STYLE = 'bezier';
|
|
29
|
+
export function parseEdgeStyle(v) {
|
|
30
|
+
if (v === 'straight' || v === 'bezier' || v === 'polyline')
|
|
31
|
+
return v;
|
|
32
|
+
return DEFAULT_EDGE_STYLE;
|
|
33
|
+
}
|
|
34
|
+
/** Straight segment — the arrowhead follows the chord, not the node edge. */
|
|
35
|
+
export function straightEdgePath(sx, sy, tx, ty) {
|
|
36
|
+
return `M${sx},${sy} L${tx},${ty}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Orthogonal (Manhattan) polyline: horizontal out, vertical, horizontal in.
|
|
40
|
+
* The last segment is horizontal so `marker-end` still meets the node's
|
|
41
|
+
* vertical edge the same way the cubic does.
|
|
42
|
+
*/
|
|
43
|
+
export function orthogonalEdgePath(sx, sy, tx, ty) {
|
|
44
|
+
const midX = sx + (tx - sx) / 2;
|
|
45
|
+
return `M${sx},${sy} H${midX} V${ty} H${tx}`;
|
|
46
|
+
}
|
|
29
47
|
/**
|
|
30
48
|
* Builds the SVG `d` for a horizontal-tangent cubic Bézier from (sx,sy) to
|
|
31
49
|
* (tx,ty).
|
|
@@ -66,3 +84,14 @@ export function bowedCubicEdgePath(sx, sy, tx, ty, bowY, curvature = DEFAULT_EDG
|
|
|
66
84
|
const handle = Math.max(EDGE_MIN_HANDLE, Math.min(Math.abs(dx) * DX_FACTOR, MAX_HANDLE)) * curvature;
|
|
67
85
|
return `M${sx},${sy} C${sx + handle},${bowY} ${tx - handle},${bowY} ${tx},${ty}`;
|
|
68
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Picks the SVG `d` for a Goals / DGCA / DGA preview edge.
|
|
89
|
+
* Unknown styles fall back to the historical cubic.
|
|
90
|
+
*/
|
|
91
|
+
export function previewEdgePath(sx, sy, tx, ty, style = DEFAULT_EDGE_STYLE, curvature = DEFAULT_EDGE_CURVATURE, entryCurvature) {
|
|
92
|
+
if (style === 'straight')
|
|
93
|
+
return straightEdgePath(sx, sy, tx, ty);
|
|
94
|
+
if (style === 'polyline')
|
|
95
|
+
return orthogonalEdgePath(sx, sy, tx, ty);
|
|
96
|
+
return horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature);
|
|
97
|
+
}
|
package/dist/fgca/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { buildFGCALayout, NODE_WIDTH, NODE_HEIGHT, COLUMN_GAP, ROW_GAP, COLUMN_BG } from "./layout";
|
|
2
2
|
export type { FGCALayoutInput } from "./layout";
|
|
3
|
-
export { layoutFGCAPreview, selectScopedFGCA, FGCA_NODE_W, FGCA_NODE_H, FGCA_HEADER_H, FGCA_PAD, FGCA_DEFAULT_COL_GAP, FGCA_DEFAULT_ROW_GAP, } from "./preview-layout";
|
|
3
|
+
export { layoutFGCAPreview, selectScopedFGCA, chainColumnOptions, sanitizeChainScope, FGCA_NODE_W, FGCA_NODE_H, FGCA_HEADER_H, FGCA_PAD, FGCA_DEFAULT_COL_GAP, FGCA_DEFAULT_ROW_GAP, } from "./preview-layout";
|
|
4
4
|
export type { FGCAPreviewColumn, FGCAPreviewDoc, FGCAPreviewLayout, FGCAPreviewLayoutOptions, FGCAPreviewNode, FGCAPreviewEdge, FGCAPreviewColumnPos, } from "./preview-layout";
|
|
5
5
|
export { buildChainTable } from "./chain-table";
|
|
6
6
|
export type { ChainTable, ChainTableCell, ChainColumn, ChainCell, ChainTableOptions } from "./chain-table";
|
package/dist/fgca/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { buildFGCALayout, NODE_WIDTH, NODE_HEIGHT, COLUMN_GAP, ROW_GAP, COLUMN_BG } from "./layout.js";
|
|
2
|
-
export { layoutFGCAPreview, selectScopedFGCA, FGCA_NODE_W, FGCA_NODE_H, FGCA_HEADER_H, FGCA_PAD, FGCA_DEFAULT_COL_GAP, FGCA_DEFAULT_ROW_GAP, } from "./preview-layout.js";
|
|
2
|
+
export { layoutFGCAPreview, selectScopedFGCA, chainColumnOptions, sanitizeChainScope, FGCA_NODE_W, FGCA_NODE_H, FGCA_HEADER_H, FGCA_PAD, FGCA_DEFAULT_COL_GAP, FGCA_DEFAULT_ROW_GAP, } from "./preview-layout.js";
|
|
3
3
|
export { buildChainTable } from "./chain-table.js";
|
|
4
4
|
export { validateFGCADoc } from "./validate.js";
|
|
5
5
|
export { ALL_FGCA_COLUMNS, FGCA_COLUMN_LABELS, } from "./types.js";
|
|
@@ -44,8 +44,13 @@ export declare function isDgcaChangesLayerOff(input: unknown): boolean;
|
|
|
44
44
|
* one action or change references an out-of-scope goal, `scopeCaption` is set true.
|
|
45
45
|
* @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
|
|
46
46
|
* in the catalogue, pass their IDs here to suppress FGCA-008 errors.
|
|
47
|
+
* @param progressData Optional map of action ID to progress data (percent + computedAt timestamp).
|
|
48
|
+
* Consumed from `__progressData` metadata field if provided there.
|
|
47
49
|
*/
|
|
48
|
-
export declare function parseCanonicalFGCA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null
|
|
50
|
+
export declare function parseCanonicalFGCA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null, progressData?: Map<string, {
|
|
51
|
+
percent: number;
|
|
52
|
+
computedAt: string;
|
|
53
|
+
}> | null): CanonicalFGCAResult;
|
|
49
54
|
/** On success, the internal-form FGCADoc derived from canonical FGA input. */
|
|
50
55
|
export interface CanonicalFGAResult extends ValidationResult {
|
|
51
56
|
parsed?: FGCADoc;
|
|
@@ -69,4 +74,7 @@ export interface CanonicalFGAResult extends ValidationResult {
|
|
|
69
74
|
* the canonical FGA path is unit-testable and the library owns the single
|
|
70
75
|
* canon shape — flat form only, no `fga:` wrapper.
|
|
71
76
|
*/
|
|
72
|
-
export declare function parseCanonicalFGA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null
|
|
77
|
+
export declare function parseCanonicalFGA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null, progressData?: Map<string, {
|
|
78
|
+
percent: number;
|
|
79
|
+
computedAt: string;
|
|
80
|
+
}> | null): CanonicalFGAResult;
|
|
@@ -67,8 +67,10 @@ export function isDgcaChangesLayerOff(input) {
|
|
|
67
67
|
* one action or change references an out-of-scope goal, `scopeCaption` is set true.
|
|
68
68
|
* @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
|
|
69
69
|
* in the catalogue, pass their IDs here to suppress FGCA-008 errors.
|
|
70
|
+
* @param progressData Optional map of action ID to progress data (percent + computedAt timestamp).
|
|
71
|
+
* Consumed from `__progressData` metadata field if provided there.
|
|
70
72
|
*/
|
|
71
|
-
export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds) {
|
|
73
|
+
export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds, progressData) {
|
|
72
74
|
const errors = [];
|
|
73
75
|
const warnings = [];
|
|
74
76
|
if (!input || typeof input !== 'object') {
|
|
@@ -271,11 +273,14 @@ export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds
|
|
|
271
273
|
const internalActivities = activities.map((el, i) => {
|
|
272
274
|
const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`, outOfScopeGoalIds);
|
|
273
275
|
const typeVal = typeof el['type'] === 'string' ? el['type'] : undefined;
|
|
276
|
+
const actId = String(el['id']);
|
|
277
|
+
const progressVal = progressData?.get(actId);
|
|
274
278
|
return {
|
|
275
|
-
id:
|
|
279
|
+
id: actId,
|
|
276
280
|
name: String(el['name'] ?? ''),
|
|
277
281
|
goal_id: goalRefs.length > 0 ? goalRefs[0] : null,
|
|
278
282
|
...(typeVal !== undefined ? { type: typeVal } : {}),
|
|
283
|
+
...(progressVal !== undefined ? { progress: progressVal } : {}),
|
|
279
284
|
};
|
|
280
285
|
});
|
|
281
286
|
// FACTOR.references_constraint grammar check (FGCA-015).
|
|
@@ -334,7 +339,7 @@ const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
|
334
339
|
* the canonical FGA path is unit-testable and the library owns the single
|
|
335
340
|
* canon shape — flat form only, no `fga:` wrapper.
|
|
336
341
|
*/
|
|
337
|
-
export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds) {
|
|
342
|
+
export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds, progressData) {
|
|
338
343
|
if (!input || typeof input !== 'object') {
|
|
339
344
|
return { valid: false, errors: [{ code: 'FGA-001', message: 'document root is not an object' }], warnings: [] };
|
|
340
345
|
}
|
|
@@ -361,7 +366,7 @@ export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds)
|
|
|
361
366
|
// codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
|
|
362
367
|
// are unreachable here because `changes` is empty.
|
|
363
368
|
const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [] };
|
|
364
|
-
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds);
|
|
369
|
+
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
365
370
|
const remap = {
|
|
366
371
|
'FGCA-001': 'FGA-001',
|
|
367
372
|
'FGCA-002': 'FGA-002',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ChainColumnKey, ChainScope, FgcaScope } from '../scope.js';
|
|
2
2
|
export type FGCAPreviewColumn = 'driver' | 'goal' | 'change' | 'activity';
|
|
3
3
|
export interface FGCAPreviewFactor {
|
|
4
4
|
id: number | string;
|
|
@@ -22,6 +22,10 @@ export interface FGCAPreviewActivity {
|
|
|
22
22
|
id: number | string;
|
|
23
23
|
name: string;
|
|
24
24
|
goal_id?: number | string | null;
|
|
25
|
+
progress?: {
|
|
26
|
+
percent: number;
|
|
27
|
+
computedAt: string;
|
|
28
|
+
};
|
|
25
29
|
}
|
|
26
30
|
/** Structural input the preview layout needs — a subset of the parsed DGCA/DGA doc. */
|
|
27
31
|
export interface FGCAPreviewDoc {
|
|
@@ -37,32 +41,59 @@ export interface FGCAPreviewLayoutOptions {
|
|
|
37
41
|
colGap?: number;
|
|
38
42
|
/** Vertical gap (px) between stacked nodes within a column. Default matches the historical hardcoded value. */
|
|
39
43
|
rowGap?: number;
|
|
40
|
-
/** Trim to a level cap
|
|
41
|
-
scope?:
|
|
44
|
+
/** Trim to a level cap, a single root goal, or per-column chain filters. Defaults to 'all'. */
|
|
45
|
+
scope?: FgcaScope;
|
|
42
46
|
/** Entity node width (px). Default {@link FGCA_NODE_W}. */
|
|
43
47
|
nodeWidth?: number;
|
|
44
48
|
/** Entity node height (px). Default {@link FGCA_NODE_H}. */
|
|
45
49
|
nodeHeight?: number;
|
|
46
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Dropdown options for one chain column, cascaded from the other selected
|
|
53
|
+
* filters (this column's own filter is ignored so the user can still switch it).
|
|
54
|
+
*/
|
|
55
|
+
export declare function chainColumnOptions(doc: FGCAPreviewDoc, scope: ChainScope, column: ChainColumnKey, hideChanges?: boolean): Array<{
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Drop column filters that are no longer reachable given the others.
|
|
61
|
+
* `justChanged` is kept even when incompatible — the last pick wins, neighbours yield.
|
|
62
|
+
*/
|
|
63
|
+
export declare function sanitizeChainScope(doc: FGCAPreviewDoc, scope: ChainScope, opts?: {
|
|
64
|
+
justChanged?: ChainColumnKey;
|
|
65
|
+
hideChanges?: boolean;
|
|
66
|
+
}): ChainScope;
|
|
47
67
|
/**
|
|
48
68
|
* Trims a DGCA/DGA doc to a scope.
|
|
49
69
|
*
|
|
50
70
|
* DGCA/DGA goals are flat (no parent_id), so:
|
|
51
71
|
* - 'level' → goals with `(level ?? 0) <= maxLevel`.
|
|
52
72
|
* - 'root' → the single goal whose id matches `rootGoalId` (empty when absent).
|
|
73
|
+
* - 'chain' → AND of per-column ids (Driver / Goal / Change / Action); empty
|
|
74
|
+
* ids are All. DGA passes hideChanges so a Change filter is ignored.
|
|
53
75
|
*
|
|
54
76
|
* Factors, changes and activities are then kept only when they touch a visible
|
|
55
77
|
* goal: a factor referenced by a visible goal, a change whose `goal_id` is
|
|
56
78
|
* visible, an activity bound to a visible goal directly or via a visible
|
|
57
79
|
* change. Pure and exported so an access-control layer can reuse it.
|
|
58
80
|
*/
|
|
59
|
-
export declare function selectScopedFGCA(doc: FGCAPreviewDoc, scope:
|
|
81
|
+
export declare function selectScopedFGCA(doc: FGCAPreviewDoc, scope: FgcaScope, opts?: {
|
|
82
|
+
hideChanges?: boolean;
|
|
83
|
+
}): FGCAPreviewDoc;
|
|
60
84
|
export interface FGCAPreviewNode {
|
|
61
85
|
id: string;
|
|
62
86
|
x: number;
|
|
63
87
|
y: number;
|
|
64
88
|
label: string;
|
|
65
89
|
col: FGCAPreviewColumn;
|
|
90
|
+
type?: string;
|
|
91
|
+
progress?: {
|
|
92
|
+
percent: number;
|
|
93
|
+
computedAt: string;
|
|
94
|
+
};
|
|
95
|
+
/** Marks a synthesized node (e.g. virtual Change) vs. a real entity. */
|
|
96
|
+
virtual?: boolean;
|
|
66
97
|
}
|
|
67
98
|
export interface FGCAPreviewEdge {
|
|
68
99
|
sx: number;
|
|
@@ -6,22 +6,198 @@
|
|
|
6
6
|
// so the gap geometry is unit-testable: the extension has no test harness.
|
|
7
7
|
//
|
|
8
8
|
// No React, no DOM, no vscode. Safe to call in Node.js or a test environment.
|
|
9
|
+
import { isChainScope } from '../scope.js';
|
|
9
10
|
import { ENTITY_NODE_SIZE } from '../node-size-presets.js';
|
|
11
|
+
function sid(id) {
|
|
12
|
+
return String(id);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Every Driver–Goal–Change–Action thread in the document, plus a singleton
|
|
16
|
+
* path for any entity that is not on a connected thread (orphans). Used to
|
|
17
|
+
* AND column filters: a node stays visible when it appears on at least one
|
|
18
|
+
* path that includes every selected id.
|
|
19
|
+
*/
|
|
20
|
+
function enumerateChainPaths(doc) {
|
|
21
|
+
const paths = [];
|
|
22
|
+
const seen = {
|
|
23
|
+
driver: new Set(),
|
|
24
|
+
goal: new Set(),
|
|
25
|
+
change: new Set(),
|
|
26
|
+
activity: new Set(),
|
|
27
|
+
};
|
|
28
|
+
const mark = (p) => {
|
|
29
|
+
paths.push(p);
|
|
30
|
+
if (p.driverId)
|
|
31
|
+
seen.driver.add(p.driverId);
|
|
32
|
+
if (p.goalId)
|
|
33
|
+
seen.goal.add(p.goalId);
|
|
34
|
+
if (p.changeId)
|
|
35
|
+
seen.change.add(p.changeId);
|
|
36
|
+
if (p.activityId)
|
|
37
|
+
seen.activity.add(p.activityId);
|
|
38
|
+
};
|
|
39
|
+
const changes = doc.changes ?? [];
|
|
40
|
+
for (const g of doc.goals) {
|
|
41
|
+
const goalId = sid(g.id);
|
|
42
|
+
const driverIds = (g.factor ?? []).map(f => sid(f.id));
|
|
43
|
+
const drivers = driverIds.length > 0 ? driverIds : [undefined];
|
|
44
|
+
const goalChanges = changes.filter(c => sid(c.goal_id) === goalId);
|
|
45
|
+
const coveredActs = new Set(goalChanges.flatMap(c => c.activity_ids.map(sid)));
|
|
46
|
+
const directActs = doc.activities.filter(a => a.goal_id != null && sid(a.goal_id) === goalId && !coveredActs.has(sid(a.id)));
|
|
47
|
+
const slots = [];
|
|
48
|
+
for (const c of goalChanges) {
|
|
49
|
+
const acts = c.activity_ids.map(sid);
|
|
50
|
+
slots.push({ changeId: sid(c.id), activityIds: acts.length > 0 ? acts : [undefined] });
|
|
51
|
+
}
|
|
52
|
+
for (const a of directActs) {
|
|
53
|
+
slots.push({ changeId: undefined, activityIds: [sid(a.id)] });
|
|
54
|
+
}
|
|
55
|
+
if (slots.length === 0) {
|
|
56
|
+
slots.push({ changeId: undefined, activityIds: [undefined] });
|
|
57
|
+
}
|
|
58
|
+
for (const d of drivers) {
|
|
59
|
+
for (const slot of slots) {
|
|
60
|
+
for (const a of slot.activityIds) {
|
|
61
|
+
mark({ driverId: d, goalId, changeId: slot.changeId, activityId: a });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const f of doc.factors) {
|
|
67
|
+
const id = sid(f.id);
|
|
68
|
+
if (!seen.driver.has(id))
|
|
69
|
+
mark({ driverId: id });
|
|
70
|
+
}
|
|
71
|
+
for (const g of doc.goals) {
|
|
72
|
+
const id = sid(g.id);
|
|
73
|
+
if (!seen.goal.has(id))
|
|
74
|
+
mark({ goalId: id });
|
|
75
|
+
}
|
|
76
|
+
for (const c of changes) {
|
|
77
|
+
const id = sid(c.id);
|
|
78
|
+
if (!seen.change.has(id))
|
|
79
|
+
mark({ changeId: id, goalId: sid(c.goal_id) });
|
|
80
|
+
}
|
|
81
|
+
for (const a of doc.activities) {
|
|
82
|
+
const id = sid(a.id);
|
|
83
|
+
if (!seen.activity.has(id)) {
|
|
84
|
+
mark({ activityId: id, goalId: a.goal_id != null ? sid(a.goal_id) : undefined });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return paths;
|
|
88
|
+
}
|
|
89
|
+
function selectChainScopedFGCA(doc, scope, hideChanges) {
|
|
90
|
+
const driverId = scope.driverId || undefined;
|
|
91
|
+
const goalId = scope.goalId || undefined;
|
|
92
|
+
const changeId = hideChanges ? undefined : (scope.changeId || undefined);
|
|
93
|
+
const activityId = scope.activityId || undefined;
|
|
94
|
+
if (!driverId && !goalId && !changeId && !activityId)
|
|
95
|
+
return doc;
|
|
96
|
+
const matched = enumerateChainPaths(doc).filter(p => {
|
|
97
|
+
if (driverId && p.driverId !== driverId)
|
|
98
|
+
return false;
|
|
99
|
+
if (goalId && p.goalId !== goalId)
|
|
100
|
+
return false;
|
|
101
|
+
if (changeId && p.changeId !== changeId)
|
|
102
|
+
return false;
|
|
103
|
+
if (activityId && p.activityId !== activityId)
|
|
104
|
+
return false;
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
const driverIds = new Set();
|
|
108
|
+
const goalIds = new Set();
|
|
109
|
+
const changeIds = new Set();
|
|
110
|
+
const activityIds = new Set();
|
|
111
|
+
for (const p of matched) {
|
|
112
|
+
if (p.driverId)
|
|
113
|
+
driverIds.add(p.driverId);
|
|
114
|
+
if (p.goalId)
|
|
115
|
+
goalIds.add(p.goalId);
|
|
116
|
+
if (p.changeId)
|
|
117
|
+
changeIds.add(p.changeId);
|
|
118
|
+
if (p.activityId)
|
|
119
|
+
activityIds.add(p.activityId);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
factors: doc.factors.filter(f => driverIds.has(sid(f.id))),
|
|
123
|
+
goals: doc.goals.filter(g => goalIds.has(sid(g.id))),
|
|
124
|
+
changes: doc.changes === undefined ? undefined : doc.changes.filter(c => changeIds.has(sid(c.id))),
|
|
125
|
+
activities: doc.activities.filter(a => activityIds.has(sid(a.id))),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const CHAIN_COLUMN_KEYS = ['driverId', 'goalId', 'changeId', 'activityId'];
|
|
129
|
+
function columnEntities(doc, column) {
|
|
130
|
+
if (column === 'driverId')
|
|
131
|
+
return doc.factors.map(f => ({ id: sid(f.id), name: f.name ?? '' }));
|
|
132
|
+
if (column === 'goalId')
|
|
133
|
+
return doc.goals.map(g => ({ id: sid(g.id), name: g.name ?? '' }));
|
|
134
|
+
if (column === 'changeId')
|
|
135
|
+
return (doc.changes ?? []).map(c => ({ id: sid(c.id), name: c.name ?? '' }));
|
|
136
|
+
return doc.activities.map(a => ({ id: sid(a.id), name: a.name ?? '' }));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Dropdown options for one chain column, cascaded from the other selected
|
|
140
|
+
* filters (this column's own filter is ignored so the user can still switch it).
|
|
141
|
+
*/
|
|
142
|
+
export function chainColumnOptions(doc, scope, column, hideChanges = false) {
|
|
143
|
+
if (hideChanges && column === 'changeId')
|
|
144
|
+
return [];
|
|
145
|
+
const withoutSelf = { ...scope, mode: 'chain', [column]: undefined };
|
|
146
|
+
const filtered = selectChainScopedFGCA(doc, withoutSelf, hideChanges);
|
|
147
|
+
return columnEntities(filtered, column);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Drop column filters that are no longer reachable given the others.
|
|
151
|
+
* `justChanged` is kept even when incompatible — the last pick wins, neighbours yield.
|
|
152
|
+
*/
|
|
153
|
+
export function sanitizeChainScope(doc, scope, opts = {}) {
|
|
154
|
+
const next = {
|
|
155
|
+
mode: 'chain',
|
|
156
|
+
driverId: scope.driverId || undefined,
|
|
157
|
+
goalId: scope.goalId || undefined,
|
|
158
|
+
changeId: opts.hideChanges ? undefined : (scope.changeId || undefined),
|
|
159
|
+
activityId: scope.activityId || undefined,
|
|
160
|
+
};
|
|
161
|
+
let changed = true;
|
|
162
|
+
while (changed) {
|
|
163
|
+
changed = false;
|
|
164
|
+
for (const key of CHAIN_COLUMN_KEYS) {
|
|
165
|
+
if (key === opts.justChanged)
|
|
166
|
+
continue;
|
|
167
|
+
if (opts.hideChanges && key === 'changeId')
|
|
168
|
+
continue;
|
|
169
|
+
const id = next[key];
|
|
170
|
+
if (!id)
|
|
171
|
+
continue;
|
|
172
|
+
const available = new Set(chainColumnOptions(doc, next, key, opts.hideChanges).map(o => o.id));
|
|
173
|
+
if (!available.has(id)) {
|
|
174
|
+
next[key] = undefined;
|
|
175
|
+
changed = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return next;
|
|
180
|
+
}
|
|
10
181
|
/**
|
|
11
182
|
* Trims a DGCA/DGA doc to a scope.
|
|
12
183
|
*
|
|
13
184
|
* DGCA/DGA goals are flat (no parent_id), so:
|
|
14
185
|
* - 'level' → goals with `(level ?? 0) <= maxLevel`.
|
|
15
186
|
* - 'root' → the single goal whose id matches `rootGoalId` (empty when absent).
|
|
187
|
+
* - 'chain' → AND of per-column ids (Driver / Goal / Change / Action); empty
|
|
188
|
+
* ids are All. DGA passes hideChanges so a Change filter is ignored.
|
|
16
189
|
*
|
|
17
190
|
* Factors, changes and activities are then kept only when they touch a visible
|
|
18
191
|
* goal: a factor referenced by a visible goal, a change whose `goal_id` is
|
|
19
192
|
* visible, an activity bound to a visible goal directly or via a visible
|
|
20
193
|
* change. Pure and exported so an access-control layer can reuse it.
|
|
21
194
|
*/
|
|
22
|
-
export function selectScopedFGCA(doc, scope) {
|
|
195
|
+
export function selectScopedFGCA(doc, scope, opts = {}) {
|
|
23
196
|
if (scope.mode === 'all')
|
|
24
197
|
return doc;
|
|
198
|
+
if (isChainScope(scope)) {
|
|
199
|
+
return selectChainScopedFGCA(doc, scope, opts.hideChanges === true);
|
|
200
|
+
}
|
|
25
201
|
const visibleGoals = scope.mode === 'level'
|
|
26
202
|
? doc.goals.filter(g => (g.level ?? 0) <= scope.maxLevel)
|
|
27
203
|
: doc.goals.filter(g => String(g.id) === scope.rootGoalId);
|
|
@@ -50,17 +226,30 @@ export const FGCA_DEFAULT_ROW_GAP = 20;
|
|
|
50
226
|
export function layoutFGCAPreview(inputDoc, options = {}) {
|
|
51
227
|
const { hideChanges = false, colGap = FGCA_DEFAULT_COL_GAP, rowGap = FGCA_DEFAULT_ROW_GAP, scope = { mode: 'all' }, nodeWidth = FGCA_NODE_W, nodeHeight = FGCA_NODE_H, } = options;
|
|
52
228
|
// Trim to scope first; everything below lays out the visible subset only.
|
|
53
|
-
const doc = selectScopedFGCA(inputDoc, scope);
|
|
229
|
+
const doc = selectScopedFGCA(inputDoc, scope, { hideChanges });
|
|
54
230
|
const colStride = nodeWidth + colGap;
|
|
55
231
|
const cols = hideChanges
|
|
56
232
|
? ['driver', 'goal', 'activity']
|
|
57
233
|
: ['driver', 'goal', 'change', 'activity'];
|
|
58
234
|
const changes = doc.changes ?? [];
|
|
235
|
+
const coveredActivities = new Set(changes.flatMap(c => c.activity_ids.map(String)));
|
|
236
|
+
// Synthesize virtual Change nodes for activities without a real Change.
|
|
237
|
+
// Each virtual node is 1:1 with one activity (not shared across activities).
|
|
238
|
+
const virtualChanges = [];
|
|
239
|
+
for (const a of doc.activities) {
|
|
240
|
+
if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
|
|
241
|
+
const virtualId = `vchange_activity_${a.id}`;
|
|
242
|
+
virtualChanges.push({ id: virtualId, activityId: String(a.id), goalId: String(a.goal_id) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
59
245
|
const colItems = {
|
|
60
246
|
driver: doc.factors.map(f => ({ id: `driver_${f.id}`, label: f.name })),
|
|
61
247
|
goal: doc.goals.map(g => ({ id: `goal_${g.id}`, label: g.name })),
|
|
62
|
-
change:
|
|
63
|
-
|
|
248
|
+
change: [
|
|
249
|
+
...changes.map(c => ({ id: `change_${c.id}`, label: c.name })),
|
|
250
|
+
...virtualChanges.map(v => ({ id: v.id, label: '–', virtual: true })),
|
|
251
|
+
],
|
|
252
|
+
activity: doc.activities.map(a => ({ id: `activity_${a.id}`, label: a.name, progress: a.progress })),
|
|
64
253
|
};
|
|
65
254
|
// Build predecessor map: for each node, which node IDs in the previous column
|
|
66
255
|
// connect to it from the left. Used for barycenter crossing minimization.
|
|
@@ -72,13 +261,17 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
|
|
|
72
261
|
for (const c of changes) {
|
|
73
262
|
predecessors.set(`change_${c.id}`, [`goal_${c.goal_id}`]);
|
|
74
263
|
}
|
|
75
|
-
const
|
|
264
|
+
for (const v of virtualChanges) {
|
|
265
|
+
predecessors.set(v.id, [`goal_${v.goalId}`]);
|
|
266
|
+
}
|
|
76
267
|
for (const a of doc.activities) {
|
|
77
268
|
const preds = changes
|
|
78
269
|
.filter(c => c.activity_ids.map(String).includes(String(a.id)))
|
|
79
270
|
.map(c => `change_${c.id}`);
|
|
80
|
-
|
|
81
|
-
|
|
271
|
+
const virtualChange = virtualChanges.find(v => v.activityId === String(a.id));
|
|
272
|
+
if (virtualChange) {
|
|
273
|
+
preds.push(virtualChange.id);
|
|
274
|
+
}
|
|
82
275
|
predecessors.set(`activity_${a.id}`, preds);
|
|
83
276
|
}
|
|
84
277
|
}
|
|
@@ -119,7 +312,16 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
|
|
|
119
312
|
const items = ci === 0 ? colItems[col] : barycentricSort(colItems[col], yCenters);
|
|
120
313
|
let y = FGCA_PAD + FGCA_HEADER_H + rowGap;
|
|
121
314
|
for (const item of items) {
|
|
122
|
-
const node = {
|
|
315
|
+
const node = {
|
|
316
|
+
id: item.id,
|
|
317
|
+
x,
|
|
318
|
+
y,
|
|
319
|
+
label: item.label,
|
|
320
|
+
col,
|
|
321
|
+
type: item.type,
|
|
322
|
+
progress: item.progress,
|
|
323
|
+
virtual: item.virtual,
|
|
324
|
+
};
|
|
123
325
|
nodes.push(node);
|
|
124
326
|
nodeMap.set(item.id, node);
|
|
125
327
|
yCenters.set(item.id, y + nodeHeight / 2);
|
|
@@ -158,12 +360,10 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
|
|
|
158
360
|
for (const c of changes)
|
|
159
361
|
for (const aid of c.activity_ids)
|
|
160
362
|
addEdge(`change_${c.id}`, `activity_${aid}`);
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
}
|
|
363
|
+
for (const v of virtualChanges)
|
|
364
|
+
addEdge(`goal_${v.goalId}`, v.id);
|
|
365
|
+
for (const v of virtualChanges)
|
|
366
|
+
addEdge(v.id, `activity_${v.activityId}`);
|
|
167
367
|
}
|
|
168
368
|
const maxNodeBottom = nodes.reduce((m, n) => Math.max(m, n.y + nodeHeight), FGCA_PAD + FGCA_HEADER_H + rowGap + nodeHeight);
|
|
169
369
|
const width = FGCA_PAD * 2 + cols.length * colStride - colGap;
|
package/dist/fgca/types.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ 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
|
+
progress?: {
|
|
28
|
+
percent: number;
|
|
29
|
+
computedAt: string;
|
|
30
|
+
};
|
|
27
31
|
}
|
|
28
32
|
export interface ActivityTypeItem {
|
|
29
33
|
id: number | string;
|
package/dist/scope.d.ts
CHANGED
|
@@ -8,10 +8,26 @@ export type Scope = {
|
|
|
8
8
|
mode: 'root';
|
|
9
9
|
rootGoalId: string;
|
|
10
10
|
};
|
|
11
|
+
/** Column filters on a DGCA/DGA chain. Empty / omitted id = All for that column. */
|
|
12
|
+
export type ChainColumnKey = 'driverId' | 'goalId' | 'changeId' | 'activityId';
|
|
13
|
+
export type ChainScope = {
|
|
14
|
+
mode: 'chain';
|
|
15
|
+
driverId?: string;
|
|
16
|
+
goalId?: string;
|
|
17
|
+
changeId?: string;
|
|
18
|
+
activityId?: string;
|
|
19
|
+
};
|
|
20
|
+
/** Scope accepted by DGCA/DGA layout — Goals tree still uses {@link Scope} only. */
|
|
21
|
+
export type FgcaScope = Scope | ChainScope;
|
|
11
22
|
/** Default scope — render everything. */
|
|
12
23
|
export declare const SCOPE_ALL: Scope;
|
|
24
|
+
export declare function isChainScope(scope: FgcaScope): scope is ChainScope;
|
|
25
|
+
/** True when at least one column filter is set. */
|
|
26
|
+
export declare function chainScopeActive(scope: ChainScope): boolean;
|
|
13
27
|
/** Warning code emitted when a root-mode scope names a goal that isn't in the document. */
|
|
14
28
|
export declare const SCOPE_MISSING_ROOT_CODE = "SCOPE-001";
|
|
29
|
+
/** Warning code emitted when a chain-column filter names an id that isn't in the document. */
|
|
30
|
+
export declare const SCOPE_MISSING_CHAIN_CODE = "SCOPE-002";
|
|
15
31
|
/**
|
|
16
32
|
* Returns the SCOPE-001 warning when `scope` is root-mode and `rootGoalId` is
|
|
17
33
|
* not among the document's goal ids; otherwise null. Goal ids are compared as
|
|
@@ -21,3 +37,14 @@ export declare const SCOPE_MISSING_ROOT_CODE = "SCOPE-001";
|
|
|
21
37
|
* functions independently return an empty layout in the same situation.
|
|
22
38
|
*/
|
|
23
39
|
export declare function checkScopeRoot(scope: Scope, goalIds: Iterable<string | number>): ValidationWarning | null;
|
|
40
|
+
/**
|
|
41
|
+
* Returns a SCOPE-002 warning when a chain filter names an id that is not in
|
|
42
|
+
* the document's corresponding column; otherwise null. Several missing ids
|
|
43
|
+
* are listed in one message.
|
|
44
|
+
*/
|
|
45
|
+
export declare function checkChainScope(scope: ChainScope, ids: {
|
|
46
|
+
drivers: Iterable<string | number>;
|
|
47
|
+
goals: Iterable<string | number>;
|
|
48
|
+
changes: Iterable<string | number>;
|
|
49
|
+
activities: Iterable<string | number>;
|
|
50
|
+
}, hideChanges?: boolean): ValidationWarning | null;
|
package/dist/scope.js
CHANGED
|
@@ -14,8 +14,17 @@
|
|
|
14
14
|
// subtree root and reuse exactly this filter. No RBAC here — just the filter.
|
|
15
15
|
/** Default scope — render everything. */
|
|
16
16
|
export const SCOPE_ALL = { mode: 'all' };
|
|
17
|
+
export function isChainScope(scope) {
|
|
18
|
+
return scope.mode === 'chain';
|
|
19
|
+
}
|
|
20
|
+
/** True when at least one column filter is set. */
|
|
21
|
+
export function chainScopeActive(scope) {
|
|
22
|
+
return !!(scope.driverId || scope.goalId || scope.changeId || scope.activityId);
|
|
23
|
+
}
|
|
17
24
|
/** Warning code emitted when a root-mode scope names a goal that isn't in the document. */
|
|
18
25
|
export const SCOPE_MISSING_ROOT_CODE = 'SCOPE-001';
|
|
26
|
+
/** Warning code emitted when a chain-column filter names an id that isn't in the document. */
|
|
27
|
+
export const SCOPE_MISSING_CHAIN_CODE = 'SCOPE-002';
|
|
19
28
|
/**
|
|
20
29
|
* Returns the SCOPE-001 warning when `scope` is root-mode and `rootGoalId` is
|
|
21
30
|
* not among the document's goal ids; otherwise null. Goal ids are compared as
|
|
@@ -36,3 +45,34 @@ export function checkScopeRoot(scope, goalIds) {
|
|
|
36
45
|
message: `Scope root goal "${scope.rootGoalId}" was not found in this document — nothing to show. Clear the scope or pick a goal that exists.`,
|
|
37
46
|
};
|
|
38
47
|
}
|
|
48
|
+
function idSetHas(ids, want) {
|
|
49
|
+
for (const id of ids) {
|
|
50
|
+
if (String(id) === want)
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Returns a SCOPE-002 warning when a chain filter names an id that is not in
|
|
57
|
+
* the document's corresponding column; otherwise null. Several missing ids
|
|
58
|
+
* are listed in one message.
|
|
59
|
+
*/
|
|
60
|
+
export function checkChainScope(scope, ids, hideChanges = false) {
|
|
61
|
+
if (scope.mode !== 'chain')
|
|
62
|
+
return null;
|
|
63
|
+
const missing = [];
|
|
64
|
+
if (scope.driverId && !idSetHas(ids.drivers, scope.driverId))
|
|
65
|
+
missing.push(`driver "${scope.driverId}"`);
|
|
66
|
+
if (scope.goalId && !idSetHas(ids.goals, scope.goalId))
|
|
67
|
+
missing.push(`goal "${scope.goalId}"`);
|
|
68
|
+
if (!hideChanges && scope.changeId && !idSetHas(ids.changes, scope.changeId))
|
|
69
|
+
missing.push(`change "${scope.changeId}"`);
|
|
70
|
+
if (scope.activityId && !idSetHas(ids.activities, scope.activityId))
|
|
71
|
+
missing.push(`action "${scope.activityId}"`);
|
|
72
|
+
if (missing.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
return {
|
|
75
|
+
code: SCOPE_MISSING_CHAIN_CODE,
|
|
76
|
+
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.`,
|
|
77
|
+
};
|
|
78
|
+
}
|