@transitrix/diagrams 1.12.1 → 1.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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). All four render dependency/relationship edges as a single cubic
3
- // Bézier with horizontal control handles: each control point shares its
4
- // endpoint's Y, so the tangent is horizontal at both ends and the marker-end
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
+ }
@@ -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";
@@ -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";
@@ -1,4 +1,4 @@
1
- import type { Scope } from '../scope.js';
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;
@@ -37,26 +37,46 @@ export interface FGCAPreviewLayoutOptions {
37
37
  colGap?: number;
38
38
  /** Vertical gap (px) between stacked nodes within a column. Default matches the historical hardcoded value. */
39
39
  rowGap?: number;
40
- /** Trim to a level cap or a single root goal. Defaults to 'all'. */
41
- scope?: Scope;
40
+ /** Trim to a level cap, a single root goal, or per-column chain filters. Defaults to 'all'. */
41
+ scope?: FgcaScope;
42
42
  /** Entity node width (px). Default {@link FGCA_NODE_W}. */
43
43
  nodeWidth?: number;
44
44
  /** Entity node height (px). Default {@link FGCA_NODE_H}. */
45
45
  nodeHeight?: number;
46
46
  }
47
+ /**
48
+ * Dropdown options for one chain column, cascaded from the other selected
49
+ * filters (this column's own filter is ignored so the user can still switch it).
50
+ */
51
+ export declare function chainColumnOptions(doc: FGCAPreviewDoc, scope: ChainScope, column: ChainColumnKey, hideChanges?: boolean): Array<{
52
+ id: string;
53
+ name: string;
54
+ }>;
55
+ /**
56
+ * Drop column filters that are no longer reachable given the others.
57
+ * `justChanged` is kept even when incompatible — the last pick wins, neighbours yield.
58
+ */
59
+ export declare function sanitizeChainScope(doc: FGCAPreviewDoc, scope: ChainScope, opts?: {
60
+ justChanged?: ChainColumnKey;
61
+ hideChanges?: boolean;
62
+ }): ChainScope;
47
63
  /**
48
64
  * Trims a DGCA/DGA doc to a scope.
49
65
  *
50
66
  * DGCA/DGA goals are flat (no parent_id), so:
51
67
  * - 'level' → goals with `(level ?? 0) <= maxLevel`.
52
68
  * - 'root' → the single goal whose id matches `rootGoalId` (empty when absent).
69
+ * - 'chain' → AND of per-column ids (Driver / Goal / Change / Action); empty
70
+ * ids are All. DGA passes hideChanges so a Change filter is ignored.
53
71
  *
54
72
  * Factors, changes and activities are then kept only when they touch a visible
55
73
  * goal: a factor referenced by a visible goal, a change whose `goal_id` is
56
74
  * visible, an activity bound to a visible goal directly or via a visible
57
75
  * change. Pure and exported so an access-control layer can reuse it.
58
76
  */
59
- export declare function selectScopedFGCA(doc: FGCAPreviewDoc, scope: Scope): FGCAPreviewDoc;
77
+ export declare function selectScopedFGCA(doc: FGCAPreviewDoc, scope: FgcaScope, opts?: {
78
+ hideChanges?: boolean;
79
+ }): FGCAPreviewDoc;
60
80
  export interface FGCAPreviewNode {
61
81
  id: string;
62
82
  x: 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,7 +226,7 @@ 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']
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
+ }
@@ -14,11 +14,13 @@
14
14
  */
15
15
  import { layoutFGCAPreview } from '../fgca/preview-layout.js';
16
16
  import type { FGCADoc } from '../fgca/validate.js';
17
+ import { type EdgeStyle } from '../edge-path.js';
17
18
  import { type NodeSizePreset } from '../node-size-presets.js';
18
19
  type FgcaLayout = ReturnType<typeof layoutFGCAPreview>;
19
20
  export interface RenderFgcaBodyOptions {
20
21
  nodeWidth?: number;
21
22
  nodeHeight?: number;
23
+ edgeStyle?: EdgeStyle;
22
24
  }
23
25
  /**
24
26
  * Canonical DGCA/DGA body — the column headers, node rects and edge paths that
@@ -39,6 +41,7 @@ export interface RenderFgcaOptions {
39
41
  curvature?: number;
40
42
  /** Entry curvature at the target node; defaults to `curvature` when omitted. */
41
43
  entryCurvature?: number;
44
+ edgeStyle?: EdgeStyle;
42
45
  nodeSizePreset?: NodeSizePreset;
43
46
  layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
44
47
  /** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
@@ -13,7 +13,7 @@
13
13
  * exactly as the extension preview does.
14
14
  */
15
15
  import { layoutFGCAPreview, FGCA_NODE_W, FGCA_NODE_H, FGCA_HEADER_H as HEADER_H, FGCA_PAD as PAD, } from '../fgca/preview-layout.js';
16
- import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
16
+ import { previewEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
17
17
  import { parseNodeSizePreset, resolveDgcaNodeSize } from '../node-size-presets.js';
18
18
  import { generateSvgEmbedCss } from '../theme/index.js';
19
19
  import { emitCenteredTextSvg, layoutCenteredEntityText, truncateLine } from './entity-text-layout.js';
@@ -36,12 +36,13 @@ const COL_LABELS = {
36
36
  export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature, bodyOptions = {}) {
37
37
  const nodeWidth = bodyOptions.nodeWidth ?? FGCA_NODE_W;
38
38
  const nodeHeight = bodyOptions.nodeHeight ?? FGCA_NODE_H;
39
+ const edgeStyle = bodyOptions.edgeStyle;
39
40
  const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
40
41
  const headerSvg = columns
41
42
  .map(({ col, x }) => `<text class="text-header" x="${x + nodeWidth / 2}" y="${PAD + HEADER_H / 2}" text-anchor="middle" dominant-baseline="central">${escXml(truncateLine(COL_LABELS[col], headerTruncate))}</text>`)
42
43
  .join('\n');
43
44
  const edgeSvg = edges
44
- .map((e) => `<path d="${horizontalCubicEdgePath(e.sx, e.sy, e.tx, e.ty, curvature, entryCurvature)}" class="diagram-edge" marker-end="url(#arrow)"/>`)
45
+ .map((e) => `<path d="${previewEdgePath(e.sx, e.sy, e.tx, e.ty, edgeStyle, curvature, entryCurvature)}" class="diagram-edge" marker-end="url(#arrow)"/>`)
45
46
  .join('\n');
46
47
  const nodeSvg = nodes
47
48
  .map((n) => {
@@ -66,7 +67,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
66
67
  return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
67
68
  }
68
69
  export function renderFgcaSvg(doc, options = {}) {
69
- const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, nodeSizePreset = 'normal', layoutOptions, scopeCaption = false, } = options;
70
+ const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, nodeSizePreset = 'normal', layoutOptions, scopeCaption = false, } = options;
70
71
  const hideChanges = variant === 'dga' || doc.hideChanges === true;
71
72
  const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
72
73
  const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, {
@@ -81,6 +82,7 @@ export function renderFgcaSvg(doc, options = {}) {
81
82
  const body = renderFgcaBody(columns, nodes, edges, curvature, entryCurvature, {
82
83
  nodeWidth: nodeSize.width,
83
84
  nodeHeight: nodeSize.height,
85
+ edgeStyle,
84
86
  });
85
87
  const titleSvg = title
86
88
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
@@ -1,10 +1,12 @@
1
1
  import type { GoalTree, GoalTreeLayout } from '../goals/types.js';
2
+ import { type EdgeStyle } from '../edge-path.js';
2
3
  import { type NodeSizePreset } from '../node-size-presets.js';
3
4
  import { type ThemeId } from '../theme/index.js';
4
5
  export interface RenderGoalsOptions {
5
6
  treeName?: string;
6
7
  curvature?: number;
7
8
  entryCurvature?: number;
9
+ edgeStyle?: EdgeStyle;
8
10
  nodeSizePreset?: NodeSizePreset;
9
11
  }
10
12
  /**
@@ -16,6 +18,7 @@ export declare function renderGoalsSvg(tree: GoalTree, options?: RenderGoalsOpti
16
18
  export interface RenderGoalsLayoutOptions {
17
19
  curvature?: number;
18
20
  entryCurvature?: number;
21
+ edgeStyle?: EdgeStyle;
19
22
  /** Extra vertical space reserved at the top of the canvas (e.g. for a title block). */
20
23
  topInset?: number;
21
24
  /** Raw SVG injected immediately after `<defs>` — a header line or a full title block. */
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { layoutGoalTree } from '../goals/layout.js';
14
14
  import { displayGoalId } from '../goals/parse-canonical.js';
15
- import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
15
+ import { previewEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
16
16
  import { parseNodeSizePreset, resolveGoalsNodeSize } from '../node-size-presets.js';
17
17
  import { generateSvgEmbedCss } from '../theme/index.js';
18
18
  import { emitCenteredTextSvg, layoutCenteredEntityText } from './entity-text-layout.js';
@@ -27,7 +27,7 @@ const PAD = 24;
27
27
  * with the shared theme CSS embedded so the output is self-contained.
28
28
  */
29
29
  export function renderGoalsSvg(tree, options = {}) {
30
- const { treeName = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, nodeSizePreset = 'normal' } = options;
30
+ const { treeName = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, nodeSizePreset = 'normal' } = options;
31
31
  const nodeSize = resolveGoalsNodeSize(parseNodeSizePreset(nodeSizePreset));
32
32
  const layout = layoutGoalTree(tree, {
33
33
  nodeWidth: nodeSize.width,
@@ -41,7 +41,7 @@ export function renderGoalsSvg(tree, options = {}) {
41
41
  const title = treeName
42
42
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
43
43
  : '';
44
- return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, title, embedCssTheme: 'transitrix' });
44
+ return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, edgeStyle, title, embedCssTheme: 'transitrix' });
45
45
  }
46
46
  /**
47
47
  * The single goals SVG emitter shared by every host. Takes an already-computed
@@ -52,7 +52,7 @@ export function renderGoalsSvg(tree, options = {}) {
52
52
  * and the export path own styling).
53
53
  */
54
54
  export function renderGoalsLayoutSvg(layout, options = {}) {
55
- const { curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, topInset = 0, title = '', embedCssTheme } = options;
55
+ const { curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, topInset = 0, title = '', embedCssTheme } = options;
56
56
  const w = layout.bounds.width + PAD * 2;
57
57
  const h = layout.bounds.height + PAD * 2 + topInset;
58
58
  const ox = -layout.bounds.x + PAD;
@@ -67,7 +67,7 @@ export function renderGoalsLayoutSvg(layout, options = {}) {
67
67
  const sy = s.y + oy + s.height / 2;
68
68
  const tx = t.x + ox;
69
69
  const ty = t.y + oy + t.height / 2;
70
- return horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature);
70
+ return previewEdgePath(sx, sy, tx, ty, edgeStyle, curvature, entryCurvature);
71
71
  }
72
72
  const edgeSvg = layout.edges
73
73
  .map((e) => `<path d="${edgePath(e)}" class="diagram-edge" marker-end="url(#arrow)"/>`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.12.1",
3
+ "version": "1.12.2",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
package/src/edge-path.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  // Shared edge geometry for the static notation previews (Goals, FGCA, FGA,
2
- // Activities). All four render dependency/relationship edges as a single cubic
3
- // Bézier with horizontal control handles: each control point shares its
4
- // endpoint's Y, so the tangent is horizontal at both ends and the marker-end
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
@@ -30,6 +29,30 @@ const MAX_HANDLE = 160;
30
29
  /** Multiplier applied to the base handle length. 1 = historical appearance. */
31
30
  export const DEFAULT_EDGE_CURVATURE = 1;
32
31
 
32
+ /** Path shape for Goals / DGCA / DGA preview edges. Default matches today's cubic. */
33
+ export type EdgeStyle = 'straight' | 'bezier' | 'polyline';
34
+ export const DEFAULT_EDGE_STYLE: EdgeStyle = 'bezier';
35
+
36
+ export function parseEdgeStyle(v: unknown): EdgeStyle {
37
+ if (v === 'straight' || v === 'bezier' || v === 'polyline') return v;
38
+ return DEFAULT_EDGE_STYLE;
39
+ }
40
+
41
+ /** Straight segment — the arrowhead follows the chord, not the node edge. */
42
+ export function straightEdgePath(sx: number, sy: number, tx: number, ty: number): string {
43
+ return `M${sx},${sy} L${tx},${ty}`;
44
+ }
45
+
46
+ /**
47
+ * Orthogonal (Manhattan) polyline: horizontal out, vertical, horizontal in.
48
+ * The last segment is horizontal so `marker-end` still meets the node's
49
+ * vertical edge the same way the cubic does.
50
+ */
51
+ export function orthogonalEdgePath(sx: number, sy: number, tx: number, ty: number): string {
52
+ const midX = sx + (tx - sx) / 2;
53
+ return `M${sx},${sy} H${midX} V${ty} H${tx}`;
54
+ }
55
+
33
56
  /**
34
57
  * Builds the SVG `d` for a horizontal-tangent cubic Bézier from (sx,sy) to
35
58
  * (tx,ty).
@@ -89,3 +112,21 @@ export function bowedCubicEdgePath(
89
112
  const handle = Math.max(EDGE_MIN_HANDLE, Math.min(Math.abs(dx) * DX_FACTOR, MAX_HANDLE)) * curvature;
90
113
  return `M${sx},${sy} C${sx + handle},${bowY} ${tx - handle},${bowY} ${tx},${ty}`;
91
114
  }
115
+
116
+ /**
117
+ * Picks the SVG `d` for a Goals / DGCA / DGA preview edge.
118
+ * Unknown styles fall back to the historical cubic.
119
+ */
120
+ export function previewEdgePath(
121
+ sx: number,
122
+ sy: number,
123
+ tx: number,
124
+ ty: number,
125
+ style: EdgeStyle = DEFAULT_EDGE_STYLE,
126
+ curvature: number = DEFAULT_EDGE_CURVATURE,
127
+ entryCurvature?: number,
128
+ ): string {
129
+ if (style === 'straight') return straightEdgePath(sx, sy, tx, ty);
130
+ if (style === 'polyline') return orthogonalEdgePath(sx, sy, tx, ty);
131
+ return horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature);
132
+ }
package/src/fgca/index.ts CHANGED
@@ -3,6 +3,8 @@ export type { FGCALayoutInput } from "./layout";
3
3
  export {
4
4
  layoutFGCAPreview,
5
5
  selectScopedFGCA,
6
+ chainColumnOptions,
7
+ sanitizeChainScope,
6
8
  FGCA_NODE_W,
7
9
  FGCA_NODE_H,
8
10
  FGCA_HEADER_H,
@@ -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 { Scope } from '../scope.js';
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';
@@ -49,28 +50,227 @@ export interface FGCAPreviewLayoutOptions {
49
50
  colGap?: number;
50
51
  /** Vertical gap (px) between stacked nodes within a column. Default matches the historical hardcoded value. */
51
52
  rowGap?: number;
52
- /** Trim to a level cap or a single root goal. Defaults to 'all'. */
53
- scope?: Scope;
53
+ /** Trim to a level cap, a single root goal, or per-column chain filters. Defaults to 'all'. */
54
+ scope?: FgcaScope;
54
55
  /** Entity node width (px). Default {@link FGCA_NODE_W}. */
55
56
  nodeWidth?: number;
56
57
  /** Entity node height (px). Default {@link FGCA_NODE_H}. */
57
58
  nodeHeight?: number;
58
59
  }
59
60
 
61
+ function sid(id: number | string): string {
62
+ return String(id);
63
+ }
64
+
65
+ interface ChainPath {
66
+ driverId?: string;
67
+ goalId?: string;
68
+ changeId?: string;
69
+ activityId?: string;
70
+ }
71
+
72
+ /**
73
+ * Every Driver–Goal–Change–Action thread in the document, plus a singleton
74
+ * path for any entity that is not on a connected thread (orphans). Used to
75
+ * AND column filters: a node stays visible when it appears on at least one
76
+ * path that includes every selected id.
77
+ */
78
+ function enumerateChainPaths(doc: FGCAPreviewDoc): ChainPath[] {
79
+ const paths: ChainPath[] = [];
80
+ const seen = {
81
+ driver: new Set<string>(),
82
+ goal: new Set<string>(),
83
+ change: new Set<string>(),
84
+ activity: new Set<string>(),
85
+ };
86
+ const mark = (p: ChainPath): void => {
87
+ paths.push(p);
88
+ if (p.driverId) seen.driver.add(p.driverId);
89
+ if (p.goalId) seen.goal.add(p.goalId);
90
+ if (p.changeId) seen.change.add(p.changeId);
91
+ if (p.activityId) seen.activity.add(p.activityId);
92
+ };
93
+
94
+ const changes = doc.changes ?? [];
95
+
96
+ for (const g of doc.goals) {
97
+ const goalId = sid(g.id);
98
+ const driverIds = (g.factor ?? []).map(f => sid(f.id));
99
+ const drivers: Array<string | undefined> = driverIds.length > 0 ? driverIds : [undefined];
100
+
101
+ const goalChanges = changes.filter(c => sid(c.goal_id) === goalId);
102
+ const coveredActs = new Set(goalChanges.flatMap(c => c.activity_ids.map(sid)));
103
+ const directActs = doc.activities.filter(
104
+ a => a.goal_id != null && sid(a.goal_id) === goalId && !coveredActs.has(sid(a.id)),
105
+ );
106
+
107
+ const slots: Array<{ changeId?: string; activityIds: Array<string | undefined> }> = [];
108
+ for (const c of goalChanges) {
109
+ const acts = c.activity_ids.map(sid);
110
+ slots.push({ changeId: sid(c.id), activityIds: acts.length > 0 ? acts : [undefined] });
111
+ }
112
+ for (const a of directActs) {
113
+ slots.push({ changeId: undefined, activityIds: [sid(a.id)] });
114
+ }
115
+ if (slots.length === 0) {
116
+ slots.push({ changeId: undefined, activityIds: [undefined] });
117
+ }
118
+
119
+ for (const d of drivers) {
120
+ for (const slot of slots) {
121
+ for (const a of slot.activityIds) {
122
+ mark({ driverId: d, goalId, changeId: slot.changeId, activityId: a });
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ for (const f of doc.factors) {
129
+ const id = sid(f.id);
130
+ if (!seen.driver.has(id)) mark({ driverId: id });
131
+ }
132
+ for (const g of doc.goals) {
133
+ const id = sid(g.id);
134
+ if (!seen.goal.has(id)) mark({ goalId: id });
135
+ }
136
+ for (const c of changes) {
137
+ const id = sid(c.id);
138
+ if (!seen.change.has(id)) mark({ changeId: id, goalId: sid(c.goal_id) });
139
+ }
140
+ for (const a of doc.activities) {
141
+ const id = sid(a.id);
142
+ if (!seen.activity.has(id)) {
143
+ mark({ activityId: id, goalId: a.goal_id != null ? sid(a.goal_id) : undefined });
144
+ }
145
+ }
146
+
147
+ return paths;
148
+ }
149
+
150
+ function selectChainScopedFGCA(
151
+ doc: FGCAPreviewDoc,
152
+ scope: ChainScope,
153
+ hideChanges: boolean,
154
+ ): FGCAPreviewDoc {
155
+ const driverId = scope.driverId || undefined;
156
+ const goalId = scope.goalId || undefined;
157
+ const changeId = hideChanges ? undefined : (scope.changeId || undefined);
158
+ const activityId = scope.activityId || undefined;
159
+ if (!driverId && !goalId && !changeId && !activityId) return doc;
160
+
161
+ const matched = enumerateChainPaths(doc).filter(p => {
162
+ if (driverId && p.driverId !== driverId) return false;
163
+ if (goalId && p.goalId !== goalId) return false;
164
+ if (changeId && p.changeId !== changeId) return false;
165
+ if (activityId && p.activityId !== activityId) return false;
166
+ return true;
167
+ });
168
+
169
+ const driverIds = new Set<string>();
170
+ const goalIds = new Set<string>();
171
+ const changeIds = new Set<string>();
172
+ const activityIds = new Set<string>();
173
+ for (const p of matched) {
174
+ if (p.driverId) driverIds.add(p.driverId);
175
+ if (p.goalId) goalIds.add(p.goalId);
176
+ if (p.changeId) changeIds.add(p.changeId);
177
+ if (p.activityId) activityIds.add(p.activityId);
178
+ }
179
+
180
+ return {
181
+ factors: doc.factors.filter(f => driverIds.has(sid(f.id))),
182
+ goals: doc.goals.filter(g => goalIds.has(sid(g.id))),
183
+ changes: doc.changes === undefined ? undefined : doc.changes.filter(c => changeIds.has(sid(c.id))),
184
+ activities: doc.activities.filter(a => activityIds.has(sid(a.id))),
185
+ };
186
+ }
187
+
188
+ const CHAIN_COLUMN_KEYS: ChainColumnKey[] = ['driverId', 'goalId', 'changeId', 'activityId'];
189
+
190
+ function columnEntities(
191
+ doc: FGCAPreviewDoc,
192
+ column: ChainColumnKey,
193
+ ): Array<{ id: string; name: string }> {
194
+ if (column === 'driverId') return doc.factors.map(f => ({ id: sid(f.id), name: f.name ?? '' }));
195
+ if (column === 'goalId') return doc.goals.map(g => ({ id: sid(g.id), name: g.name ?? '' }));
196
+ if (column === 'changeId') return (doc.changes ?? []).map(c => ({ id: sid(c.id), name: c.name ?? '' }));
197
+ return doc.activities.map(a => ({ id: sid(a.id), name: a.name ?? '' }));
198
+ }
199
+
200
+ /**
201
+ * Dropdown options for one chain column, cascaded from the other selected
202
+ * filters (this column's own filter is ignored so the user can still switch it).
203
+ */
204
+ export function chainColumnOptions(
205
+ doc: FGCAPreviewDoc,
206
+ scope: ChainScope,
207
+ column: ChainColumnKey,
208
+ hideChanges = false,
209
+ ): Array<{ id: string; name: string }> {
210
+ if (hideChanges && column === 'changeId') return [];
211
+ const withoutSelf: ChainScope = { ...scope, mode: 'chain', [column]: undefined };
212
+ const filtered = selectChainScopedFGCA(doc, withoutSelf, hideChanges);
213
+ return columnEntities(filtered, column);
214
+ }
215
+
216
+ /**
217
+ * Drop column filters that are no longer reachable given the others.
218
+ * `justChanged` is kept even when incompatible — the last pick wins, neighbours yield.
219
+ */
220
+ export function sanitizeChainScope(
221
+ doc: FGCAPreviewDoc,
222
+ scope: ChainScope,
223
+ opts: { justChanged?: ChainColumnKey; hideChanges?: boolean } = {},
224
+ ): ChainScope {
225
+ const next: ChainScope = {
226
+ mode: 'chain',
227
+ driverId: scope.driverId || undefined,
228
+ goalId: scope.goalId || undefined,
229
+ changeId: opts.hideChanges ? undefined : (scope.changeId || undefined),
230
+ activityId: scope.activityId || undefined,
231
+ };
232
+
233
+ let changed = true;
234
+ while (changed) {
235
+ changed = false;
236
+ for (const key of CHAIN_COLUMN_KEYS) {
237
+ if (key === opts.justChanged) continue;
238
+ if (opts.hideChanges && key === 'changeId') continue;
239
+ const id = next[key];
240
+ if (!id) continue;
241
+ const available = new Set(chainColumnOptions(doc, next, key, opts.hideChanges).map(o => o.id));
242
+ if (!available.has(id)) {
243
+ next[key] = undefined;
244
+ changed = true;
245
+ }
246
+ }
247
+ }
248
+ return next;
249
+ }
250
+
60
251
  /**
61
252
  * Trims a DGCA/DGA doc to a scope.
62
253
  *
63
254
  * DGCA/DGA goals are flat (no parent_id), so:
64
255
  * - 'level' → goals with `(level ?? 0) <= maxLevel`.
65
256
  * - 'root' → the single goal whose id matches `rootGoalId` (empty when absent).
257
+ * - 'chain' → AND of per-column ids (Driver / Goal / Change / Action); empty
258
+ * ids are All. DGA passes hideChanges so a Change filter is ignored.
66
259
  *
67
260
  * Factors, changes and activities are then kept only when they touch a visible
68
261
  * goal: a factor referenced by a visible goal, a change whose `goal_id` is
69
262
  * visible, an activity bound to a visible goal directly or via a visible
70
263
  * change. Pure and exported so an access-control layer can reuse it.
71
264
  */
72
- export function selectScopedFGCA(doc: FGCAPreviewDoc, scope: Scope): FGCAPreviewDoc {
265
+ export function selectScopedFGCA(
266
+ doc: FGCAPreviewDoc,
267
+ scope: FgcaScope,
268
+ opts: { hideChanges?: boolean } = {},
269
+ ): FGCAPreviewDoc {
73
270
  if (scope.mode === 'all') return doc;
271
+ if (isChainScope(scope)) {
272
+ return selectChainScopedFGCA(doc, scope, opts.hideChanges === true);
273
+ }
74
274
 
75
275
  const visibleGoals =
76
276
  scope.mode === 'level'
@@ -147,7 +347,7 @@ export function layoutFGCAPreview(
147
347
  } = options;
148
348
 
149
349
  // Trim to scope first; everything below lays out the visible subset only.
150
- const doc = selectScopedFGCA(inputDoc, scope);
350
+ const doc = selectScopedFGCA(inputDoc, scope, { hideChanges });
151
351
 
152
352
  const colStride = nodeWidth + colGap;
153
353
  const cols: FGCAPreviewColumn[] = hideChanges
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
+ }
@@ -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 { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
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,6 +61,7 @@ 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));
64
66
 
65
67
  const headerSvg = columns
@@ -71,7 +73,7 @@ export function renderFgcaBody(
71
73
  const edgeSvg = edges
72
74
  .map(
73
75
  (e) =>
74
- `<path d="${horizontalCubicEdgePath(e.sx, e.sy, e.tx, e.ty, curvature, entryCurvature)}" class="diagram-edge" marker-end="url(#arrow)"/>`,
76
+ `<path d="${previewEdgePath(e.sx, e.sy, e.tx, e.ty, edgeStyle, curvature, entryCurvature)}" class="diagram-edge" marker-end="url(#arrow)"/>`,
75
77
  )
76
78
  .join('\n');
77
79
 
@@ -108,6 +110,7 @@ export interface RenderFgcaOptions {
108
110
  curvature?: number;
109
111
  /** Entry curvature at the target node; defaults to `curvature` when omitted. */
110
112
  entryCurvature?: number;
113
+ edgeStyle?: EdgeStyle;
111
114
  nodeSizePreset?: NodeSizePreset;
112
115
  layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
113
116
  /** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
@@ -120,6 +123,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
120
123
  title = '',
121
124
  curvature = DEFAULT_EDGE_CURVATURE,
122
125
  entryCurvature,
126
+ edgeStyle,
123
127
  nodeSizePreset = 'normal',
124
128
  layoutOptions,
125
129
  scopeCaption = false,
@@ -141,6 +145,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
141
145
  const body = renderFgcaBody(columns, nodes, edges, curvature, entryCurvature, {
142
146
  nodeWidth: nodeSize.width,
143
147
  nodeHeight: nodeSize.height,
148
+ edgeStyle,
144
149
  });
145
150
 
146
151
  const titleSvg = title
@@ -13,7 +13,7 @@
13
13
  import { layoutGoalTree } from '../goals/layout.js';
14
14
  import type { GoalTree, GoalTreeLayout, LaidOutEdge } from '../goals/types.js';
15
15
  import { displayGoalId } from '../goals/parse-canonical.js';
16
- import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
16
+ import { previewEdgePath, DEFAULT_EDGE_CURVATURE, type EdgeStyle } from '../edge-path.js';
17
17
  import { parseNodeSizePreset, resolveGoalsNodeSize, type NodeSizePreset } from '../node-size-presets.js';
18
18
  import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
19
19
  import { emitCenteredTextSvg, layoutCenteredEntityText } from './entity-text-layout.js';
@@ -28,6 +28,7 @@ export interface RenderGoalsOptions {
28
28
  treeName?: string;
29
29
  curvature?: number;
30
30
  entryCurvature?: number;
31
+ edgeStyle?: EdgeStyle;
31
32
  nodeSizePreset?: NodeSizePreset;
32
33
  }
33
34
 
@@ -37,7 +38,7 @@ export interface RenderGoalsOptions {
37
38
  * with the shared theme CSS embedded so the output is self-contained.
38
39
  */
39
40
  export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {}): string {
40
- const { treeName = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, nodeSizePreset = 'normal' } = options;
41
+ const { treeName = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, nodeSizePreset = 'normal' } = options;
41
42
  const nodeSize = resolveGoalsNodeSize(parseNodeSizePreset(nodeSizePreset));
42
43
 
43
44
  const layout: GoalTreeLayout = layoutGoalTree(tree, {
@@ -55,12 +56,13 @@ export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {})
55
56
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
56
57
  : '';
57
58
 
58
- return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, title, embedCssTheme: 'transitrix' });
59
+ return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, edgeStyle, title, embedCssTheme: 'transitrix' });
59
60
  }
60
61
 
61
62
  export interface RenderGoalsLayoutOptions {
62
63
  curvature?: number;
63
64
  entryCurvature?: number;
65
+ edgeStyle?: EdgeStyle;
64
66
  /** Extra vertical space reserved at the top of the canvas (e.g. for a title block). */
65
67
  topInset?: number;
66
68
  /** Raw SVG injected immediately after `<defs>` — a header line or a full title block. */
@@ -78,7 +80,7 @@ export interface RenderGoalsLayoutOptions {
78
80
  * and the export path own styling).
79
81
  */
80
82
  export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoalsLayoutOptions = {}): string {
81
- const { curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, topInset = 0, title = '', embedCssTheme } = options;
83
+ const { curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, edgeStyle, topInset = 0, title = '', embedCssTheme } = options;
82
84
 
83
85
  const w = layout.bounds.width + PAD * 2;
84
86
  const h = layout.bounds.height + PAD * 2 + topInset;
@@ -95,7 +97,7 @@ export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoal
95
97
  const sy = s.y + oy + s.height / 2;
96
98
  const tx = t.x + ox;
97
99
  const ty = t.y + oy + t.height / 2;
98
- return horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature);
100
+ return previewEdgePath(sx, sy, tx, ty, edgeStyle, curvature, entryCurvature);
99
101
  }
100
102
 
101
103
  const edgeSvg = layout.edges