@transitrix/diagrams 1.12.0 → 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.
- package/dist/capability-map/CapabilityCardNode.d.ts +1 -0
- package/dist/capability-map/CapabilityCardNode.js +5 -4
- package/dist/capability-map/CapabilityMapView.js +1 -0
- package/dist/capability-map/dsm-layout.js +5 -4
- package/dist/capability-map/dsm-schema.d.ts +1 -0
- 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/preview-layout.d.ts +24 -4
- package/dist/fgca/preview-layout.js +178 -2
- package/dist/goals/GoalNode.d.ts +1 -0
- package/dist/goals/GoalNode.js +5 -4
- package/dist/goals/GoalTreeView.js +1 -0
- package/dist/goals/layout.js +7 -5
- package/dist/goals/types.d.ts +2 -0
- package/dist/scope.d.ts +27 -0
- package/dist/scope.js +40 -0
- package/dist/webview/render-fgca.d.ts +3 -0
- package/dist/webview/render-fgca.js +5 -3
- package/dist/webview/render-goals.d.ts +3 -0
- package/dist/webview/render-goals.js +5 -5
- package/package.json +1 -1
- package/src/capability-map/CapabilityCardNode.tsx +8 -6
- package/src/capability-map/CapabilityMapView.tsx +1 -0
- package/src/capability-map/dsm-layout.ts +5 -4
- package/src/capability-map/dsm-schema.ts +1 -0
- package/src/edge-path.ts +45 -4
- package/src/fgca/index.ts +2 -0
- package/src/fgca/preview-layout.ts +205 -5
- package/src/goals/GoalNode.tsx +8 -6
- package/src/goals/GoalTreeView.tsx +1 -0
- package/src/goals/layout.ts +7 -5
- package/src/goals/types.ts +2 -0
- package/src/scope.ts +61 -0
- package/src/webview/render-fgca.ts +7 -2
- package/src/webview/render-goals.ts +7 -5
|
@@ -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 {
|
|
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="${
|
|
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 {
|
|
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
|
|
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
|
@@ -17,6 +17,7 @@ export interface CapabilityCardNodeData {
|
|
|
17
17
|
maturityColours: Record<number, string>;
|
|
18
18
|
readOnly: boolean;
|
|
19
19
|
isDropTarget: boolean;
|
|
20
|
+
hasChildren: boolean;
|
|
20
21
|
hasHiddenChildren: boolean;
|
|
21
22
|
isCollapsed: boolean;
|
|
22
23
|
onAddChild: (parentId: number) => void;
|
|
@@ -25,7 +26,7 @@ export interface CapabilityCardNodeData {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const CapabilityCardNode = memo(({ data }: { data: CapabilityCardNodeData }) => {
|
|
28
|
-
const { capability, theme, maturityColours, readOnly, isDropTarget, hasHiddenChildren, isCollapsed } = data;
|
|
29
|
+
const { capability, theme, maturityColours, readOnly, isDropTarget, hasChildren, hasHiddenChildren, isCollapsed } = data;
|
|
29
30
|
const maturity = latestMaturityLevel(capability);
|
|
30
31
|
const dotColour = maturity !== undefined ? maturityColours[maturity] : undefined;
|
|
31
32
|
|
|
@@ -119,11 +120,11 @@ const CapabilityCardNode = memo(({ data }: { data: CapabilityCardNodeData }) =>
|
|
|
119
120
|
</button>
|
|
120
121
|
)}
|
|
121
122
|
|
|
122
|
-
{hasHiddenChildren && (
|
|
123
|
+
{(hasChildren || hasHiddenChildren) && (
|
|
123
124
|
<button
|
|
124
125
|
type="button"
|
|
125
|
-
aria-label={isCollapsed ? 'Expand branch' : 'Collapse branch'}
|
|
126
|
-
title={isCollapsed ? 'Expand branch' : 'Collapse branch'}
|
|
126
|
+
aria-label={isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch'}
|
|
127
|
+
title={isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch'}
|
|
127
128
|
onClick={(e) => {
|
|
128
129
|
e.stopPropagation();
|
|
129
130
|
data.onToggleCollapse(capability.id);
|
|
@@ -131,10 +132,11 @@ const CapabilityCardNode = memo(({ data }: { data: CapabilityCardNodeData }) =>
|
|
|
131
132
|
style={{
|
|
132
133
|
position: 'absolute', top: '50%', right: -8, transform: 'translateY(-50%)',
|
|
133
134
|
width: 16, height: 16, borderRadius: '50%', border: `1px solid ${theme.cardBorderColor}`,
|
|
134
|
-
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
135
|
+
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
136
|
+
cursor: 'pointer', zIndex: 10,
|
|
135
137
|
}}
|
|
136
138
|
>
|
|
137
|
-
{isCollapsed ? '+' : '−'}
|
|
139
|
+
{isCollapsed || hasHiddenChildren ? '+' : '−'}
|
|
138
140
|
</button>
|
|
139
141
|
)}
|
|
140
142
|
</div>
|
|
@@ -79,12 +79,13 @@ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions =
|
|
|
79
79
|
const cap = byId.get(id)!;
|
|
80
80
|
const allChildIds = children.get(id) ?? [];
|
|
81
81
|
const childIds = allChildIds.filter((c) => !hiddenSubtrees.has(c));
|
|
82
|
+
const hasChildren = allChildIds.length > 0;
|
|
82
83
|
const hasHiddenChildren = childIds.length < allChildIds.length;
|
|
83
84
|
|
|
84
85
|
if (childIds.length === 0) {
|
|
85
86
|
const y = getNextY(col);
|
|
86
87
|
advanceY(col, nodeHeight + nodeSep);
|
|
87
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
|
|
88
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasChildren, hasHiddenChildren });
|
|
88
89
|
return { top: y, bottom: y + nodeHeight };
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -96,7 +97,7 @@ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions =
|
|
|
96
97
|
const finalY = Math.max(parentY, curColY);
|
|
97
98
|
advanceY(col, finalY - curColY + nodeHeight + nodeSep);
|
|
98
99
|
|
|
99
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
|
|
100
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasChildren, hasHiddenChildren });
|
|
100
101
|
for (const cid of childIds) edges.push({ source: id, target: cid });
|
|
101
102
|
return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + nodeHeight, spanBottom) };
|
|
102
103
|
}
|
|
@@ -108,7 +109,7 @@ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions =
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
if (nodes.length === 0) {
|
|
111
|
-
const rootNode: LaidOutNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
|
|
112
|
+
const rootNode: LaidOutNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasChildren: false, hasHiddenChildren: false };
|
|
112
113
|
return { rootNode, nodes: [], edges: [], bounds: { x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight } };
|
|
113
114
|
}
|
|
114
115
|
|
|
@@ -122,7 +123,7 @@ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions =
|
|
|
122
123
|
const rootTop = Math.min(...l1Nodes.map((n) => n.y));
|
|
123
124
|
const rootBottom = Math.max(...l1Nodes.map((n) => n.y + n.height));
|
|
124
125
|
const rootY = rootTop + (rootBottom - rootTop) / 2 - nodeHeight / 2;
|
|
125
|
-
const rootNode: LaidOutNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
|
|
126
|
+
const rootNode: LaidOutNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasChildren: l1Ids.length > 0, hasHiddenChildren: false };
|
|
126
127
|
|
|
127
128
|
const rootEdges: LaidOutEdge[] = l1Ids
|
|
128
129
|
.filter((id) => !hiddenSubtrees.has(id))
|
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).
|
|
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
|
|
@@ -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
|
@@ -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';
|
|
@@ -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
|
|
53
|
-
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(
|
|
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/goals/GoalNode.tsx
CHANGED
|
@@ -19,6 +19,7 @@ export interface GoalNodeData {
|
|
|
19
19
|
theme: Required<ThemeTokens>;
|
|
20
20
|
readOnly: boolean;
|
|
21
21
|
isDropTarget: boolean;
|
|
22
|
+
hasChildren: boolean;
|
|
22
23
|
hasHiddenChildren: boolean;
|
|
23
24
|
isCollapsed: boolean;
|
|
24
25
|
onAddChild: (parentId: number, parentLevel: number) => void;
|
|
@@ -28,7 +29,7 @@ export interface GoalNodeData {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
31
|
-
const { goal, theme, readOnly, isDropTarget, hasHiddenChildren, isCollapsed } = data;
|
|
32
|
+
const { goal, theme, readOnly, isDropTarget, hasChildren, hasHiddenChildren, isCollapsed } = data;
|
|
32
33
|
const [openPanel, setOpenPanel] = useState<'positive' | 'negative' | null>(null);
|
|
33
34
|
|
|
34
35
|
const positive = (goal.factors ?? []).filter((f) => impactSign(f) !== 'negative');
|
|
@@ -192,11 +193,11 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
|
192
193
|
</button>
|
|
193
194
|
)}
|
|
194
195
|
|
|
195
|
-
{hasHiddenChildren && (
|
|
196
|
+
{(hasChildren || hasHiddenChildren) && (
|
|
196
197
|
<button
|
|
197
198
|
type="button"
|
|
198
|
-
aria-label={isCollapsed ? 'Expand branch' : 'Collapse branch'}
|
|
199
|
-
title={isCollapsed ? 'Expand branch' : 'Collapse branch'}
|
|
199
|
+
aria-label={isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch'}
|
|
200
|
+
title={isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch'}
|
|
200
201
|
onClick={(e) => {
|
|
201
202
|
e.stopPropagation();
|
|
202
203
|
data.onToggleCollapse(goal.id);
|
|
@@ -204,10 +205,11 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
|
204
205
|
style={{
|
|
205
206
|
position: 'absolute', top: '50%', right: -8, transform: 'translateY(-50%)',
|
|
206
207
|
width: 16, height: 16, borderRadius: '50%', border: `1px solid ${theme.cardBorderColor}`,
|
|
207
|
-
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
208
|
+
background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
|
|
209
|
+
cursor: 'pointer', zIndex: 10,
|
|
208
210
|
}}
|
|
209
211
|
>
|
|
210
|
-
{isCollapsed ? '+' : '−'}
|
|
212
|
+
{isCollapsed || hasHiddenChildren ? '+' : '−'}
|
|
211
213
|
</button>
|
|
212
214
|
)}
|
|
213
215
|
</div>
|
package/src/goals/layout.ts
CHANGED
|
@@ -114,15 +114,17 @@ export function layoutGoalTree(tree: GoalTree, options: LayoutOptions = {}): Goa
|
|
|
114
114
|
placed.add(id);
|
|
115
115
|
|
|
116
116
|
const col = levelToCol.get(goal.level) ?? 0;
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
117
|
+
const allChildIds = children.get(id) ?? [];
|
|
118
|
+
const childIds = allChildIds.filter(c => !hiddenSubtrees.has(c) && !placed.has(c));
|
|
119
|
+
const isCollapsedRoot = hiddenSet.has(id) && allChildIds.length > 0;
|
|
120
|
+
const hasChildren = allChildIds.length > 0;
|
|
121
|
+
const hasHiddenChildren = childIds.length < allChildIds.length;
|
|
120
122
|
|
|
121
123
|
if (childIds.length === 0) {
|
|
122
124
|
// Leaf: place at current column Y
|
|
123
125
|
const y = getNextY(col);
|
|
124
126
|
advanceY(col, nodeHeight + nodeSep);
|
|
125
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasHiddenChildren });
|
|
127
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasChildren, hasHiddenChildren });
|
|
126
128
|
return { top: y, bottom: y + nodeHeight };
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -141,7 +143,7 @@ export function layoutGoalTree(tree: GoalTree, options: LayoutOptions = {}): Goa
|
|
|
141
143
|
const finalY = Math.max(parentY, curColY);
|
|
142
144
|
advanceY(col, finalY - curColY + nodeHeight + nodeSep);
|
|
143
145
|
|
|
144
|
-
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasHiddenChildren });
|
|
146
|
+
nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasChildren, hasHiddenChildren });
|
|
145
147
|
|
|
146
148
|
for (const cid of childIds) {
|
|
147
149
|
edges.push({ source: id, target: cid });
|
package/src/goals/types.ts
CHANGED