@transitrix/diagrams 1.12.0 → 1.12.1

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.
@@ -7,6 +7,7 @@ export interface CapabilityCardNodeData {
7
7
  maturityColours: Record<number, string>;
8
8
  readOnly: boolean;
9
9
  isDropTarget: boolean;
10
+ hasChildren: boolean;
10
11
  hasHiddenChildren: boolean;
11
12
  isCollapsed: boolean;
12
13
  onAddChild: (parentId: number) => void;
@@ -9,7 +9,7 @@ function latestMaturityLevel(cap) {
9
9
  return [...cap.maturity].sort((a, b) => b.date.localeCompare(a.date))[0].level;
10
10
  }
11
11
  const CapabilityCardNode = memo(({ data }) => {
12
- const { capability, theme, maturityColours, readOnly, isDropTarget, hasHiddenChildren, isCollapsed } = data;
12
+ const { capability, theme, maturityColours, readOnly, isDropTarget, hasChildren, hasHiddenChildren, isCollapsed } = data;
13
13
  const maturity = latestMaturityLevel(capability);
14
14
  const dotColour = maturity !== undefined ? maturityColours[maturity] : undefined;
15
15
  return (_jsxs("div", { style: {
@@ -53,14 +53,15 @@ const CapabilityCardNode = memo(({ data }) => {
53
53
  position: 'absolute', bottom: -8, right: -8, width: 16, height: 16,
54
54
  borderRadius: '50%', border: '1px solid #fca5a5', background: '#ffffff',
55
55
  color: '#dc2626', fontSize: 11, lineHeight: '14px', padding: 0, cursor: 'pointer',
56
- }, children: "\u00D7" })), hasHiddenChildren && (_jsx("button", { type: "button", "aria-label": isCollapsed ? 'Expand branch' : 'Collapse branch', title: isCollapsed ? 'Expand branch' : 'Collapse branch', onClick: (e) => {
56
+ }, children: "\u00D7" })), (hasChildren || hasHiddenChildren) && (_jsx("button", { type: "button", "aria-label": isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch', title: isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch', onClick: (e) => {
57
57
  e.stopPropagation();
58
58
  data.onToggleCollapse(capability.id);
59
59
  }, style: {
60
60
  position: 'absolute', top: '50%', right: -8, transform: 'translateY(-50%)',
61
61
  width: 16, height: 16, borderRadius: '50%', border: `1px solid ${theme.cardBorderColor}`,
62
- background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0, cursor: 'pointer',
63
- }, children: isCollapsed ? '+' : '−' }))] }));
62
+ background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
63
+ cursor: 'pointer', zIndex: 10,
64
+ }, children: isCollapsed || hasHiddenChildren ? '+' : '−' }))] }));
64
65
  });
65
66
  CapabilityCardNode.displayName = 'CapabilityCardNode';
66
67
  export default CapabilityCardNode;
@@ -101,6 +101,7 @@ function CapabilityMapViewInner({ map, layout: layoutProp, layoutOptions, theme:
101
101
  maturityColours,
102
102
  readOnly,
103
103
  isDropTarget: hoveredId === n.id,
104
+ hasChildren: n.hasChildren,
104
105
  hasHiddenChildren: n.hasHiddenChildren,
105
106
  isCollapsed: collapsedIds.has(n.id),
106
107
  onAddChild,
@@ -68,11 +68,12 @@ export function layoutCapabilityMap(map, options = {}) {
68
68
  const cap = byId.get(id);
69
69
  const allChildIds = children.get(id) ?? [];
70
70
  const childIds = allChildIds.filter((c) => !hiddenSubtrees.has(c));
71
+ const hasChildren = allChildIds.length > 0;
71
72
  const hasHiddenChildren = childIds.length < allChildIds.length;
72
73
  if (childIds.length === 0) {
73
74
  const y = getNextY(col);
74
75
  advanceY(col, nodeHeight + nodeSep);
75
- nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
76
+ nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasChildren, hasHiddenChildren });
76
77
  return { top: y, bottom: y + nodeHeight };
77
78
  }
78
79
  const spans = childIds.map((cid) => placeSubtree(cid, col + 1));
@@ -82,7 +83,7 @@ export function layoutCapabilityMap(map, options = {}) {
82
83
  const curColY = getNextY(col);
83
84
  const finalY = Math.max(parentY, curColY);
84
85
  advanceY(col, finalY - curColY + nodeHeight + nodeSep);
85
- nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
86
+ nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasChildren, hasHiddenChildren });
86
87
  for (const cid of childIds)
87
88
  edges.push({ source: id, target: cid });
88
89
  return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + nodeHeight, spanBottom) };
@@ -95,7 +96,7 @@ export function layoutCapabilityMap(map, options = {}) {
95
96
  columnNextY.set(col, y + nodeSep);
96
97
  }
97
98
  if (nodes.length === 0) {
98
- const rootNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
99
+ const rootNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasChildren: false, hasHiddenChildren: false };
99
100
  return { rootNode, nodes: [], edges: [], bounds: { x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight } };
100
101
  }
101
102
  const minX = Math.min(...nodes.map((n) => n.x));
@@ -107,7 +108,7 @@ export function layoutCapabilityMap(map, options = {}) {
107
108
  const rootTop = Math.min(...l1Nodes.map((n) => n.y));
108
109
  const rootBottom = Math.max(...l1Nodes.map((n) => n.y + n.height));
109
110
  const rootY = rootTop + (rootBottom - rootTop) / 2 - nodeHeight / 2;
110
- const rootNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
111
+ const rootNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasChildren: l1Ids.length > 0, hasHiddenChildren: false };
111
112
  const rootEdges = l1Ids
112
113
  .filter((id) => !hiddenSubtrees.has(id))
113
114
  .map((id) => ({ source: 'root', target: id }));
@@ -52,6 +52,7 @@ export interface LaidOutNode {
52
52
  width: number;
53
53
  height: number;
54
54
  data: Capability | null;
55
+ hasChildren: boolean;
55
56
  hasHiddenChildren: boolean;
56
57
  }
57
58
  export interface LaidOutEdge {
@@ -6,6 +6,7 @@ export interface GoalNodeData {
6
6
  theme: Required<ThemeTokens>;
7
7
  readOnly: boolean;
8
8
  isDropTarget: boolean;
9
+ hasChildren: boolean;
9
10
  hasHiddenChildren: boolean;
10
11
  isCollapsed: boolean;
11
12
  onAddChild: (parentId: number, parentLevel: number) => void;
@@ -13,7 +13,7 @@ function impactSign(f) {
13
13
  return 'negative';
14
14
  }
15
15
  const GoalNode = memo(({ data }) => {
16
- const { goal, theme, readOnly, isDropTarget, hasHiddenChildren, isCollapsed } = data;
16
+ const { goal, theme, readOnly, isDropTarget, hasChildren, hasHiddenChildren, isCollapsed } = data;
17
17
  const [openPanel, setOpenPanel] = useState(null);
18
18
  const positive = (goal.factors ?? []).filter((f) => impactSign(f) !== 'negative');
19
19
  const negative = (goal.factors ?? []).filter((f) => impactSign(f) !== 'positive');
@@ -83,14 +83,15 @@ const GoalNode = memo(({ data }) => {
83
83
  position: 'absolute', bottom: -8, right: -8, width: 16, height: 16,
84
84
  borderRadius: '50%', border: '1px solid #fca5a5', background: '#ffffff',
85
85
  color: '#dc2626', fontSize: 11, lineHeight: '14px', padding: 0, cursor: 'pointer',
86
- }, children: "\u00D7" })), hasHiddenChildren && (_jsx("button", { type: "button", "aria-label": isCollapsed ? 'Expand branch' : 'Collapse branch', title: isCollapsed ? 'Expand branch' : 'Collapse branch', onClick: (e) => {
86
+ }, children: "\u00D7" })), (hasChildren || hasHiddenChildren) && (_jsx("button", { type: "button", "aria-label": isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch', title: isCollapsed || hasHiddenChildren ? 'Expand branch' : 'Collapse branch', onClick: (e) => {
87
87
  e.stopPropagation();
88
88
  data.onToggleCollapse(goal.id);
89
89
  }, style: {
90
90
  position: 'absolute', top: '50%', right: -8, transform: 'translateY(-50%)',
91
91
  width: 16, height: 16, borderRadius: '50%', border: `1px solid ${theme.cardBorderColor}`,
92
- background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0, cursor: 'pointer',
93
- }, children: isCollapsed ? '+' : '−' }))] }));
92
+ background: '#ffffff', color: theme.cardBorderColor, fontSize: 11, lineHeight: '14px', padding: 0,
93
+ cursor: 'pointer', zIndex: 10,
94
+ }, children: isCollapsed || hasHiddenChildren ? '+' : '−' }))] }));
94
95
  });
95
96
  GoalNode.displayName = 'GoalNode';
96
97
  export default GoalNode;
@@ -122,6 +122,7 @@ function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: the
122
122
  theme,
123
123
  readOnly,
124
124
  isDropTarget: hoveredId === n.id,
125
+ hasChildren: n.hasChildren,
125
126
  hasHiddenChildren: n.hasHiddenChildren,
126
127
  isCollapsed: collapsedIds.has(n.id),
127
128
  onAddChild,
@@ -100,14 +100,16 @@ export function layoutGoalTree(tree, options = {}) {
100
100
  return { top: 0, bottom: 0 };
101
101
  placed.add(id);
102
102
  const col = levelToCol.get(goal.level) ?? 0;
103
- const childIds = (children.get(id) ?? []).filter(c => !hiddenSubtrees.has(c) && !placed.has(c));
104
- const isCollapsedRoot = hiddenSet.has(id) && childIds.length > 0;
105
- const hasHiddenChildren = childIds.length < (children.get(id) ?? []).length;
103
+ const allChildIds = children.get(id) ?? [];
104
+ const childIds = allChildIds.filter(c => !hiddenSubtrees.has(c) && !placed.has(c));
105
+ const isCollapsedRoot = hiddenSet.has(id) && allChildIds.length > 0;
106
+ const hasChildren = allChildIds.length > 0;
107
+ const hasHiddenChildren = childIds.length < allChildIds.length;
106
108
  if (childIds.length === 0) {
107
109
  // Leaf: place at current column Y
108
110
  const y = getNextY(col);
109
111
  advanceY(col, nodeHeight + nodeSep);
110
- nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasHiddenChildren });
112
+ nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasChildren, hasHiddenChildren });
111
113
  return { top: y, bottom: y + nodeHeight };
112
114
  }
113
115
  // Place children first to get their span
@@ -122,7 +124,7 @@ export function layoutGoalTree(tree, options = {}) {
122
124
  const curColY = getNextY(col);
123
125
  const finalY = Math.max(parentY, curColY);
124
126
  advanceY(col, finalY - curColY + nodeHeight + nodeSep);
125
- nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasHiddenChildren });
127
+ nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: goal, isCollapsedRoot, hasChildren, hasHiddenChildren });
126
128
  for (const cid of childIds) {
127
129
  edges.push({ source: id, target: cid });
128
130
  }
@@ -55,6 +55,8 @@ export interface LaidOutNode {
55
55
  height: number;
56
56
  data: Goal;
57
57
  isCollapsedRoot: boolean;
58
+ /** True when this node has at least one child in the source tree (visible or hidden). */
59
+ hasChildren: boolean;
58
60
  hasHiddenChildren: boolean;
59
61
  }
60
62
  export interface LaidOutEdge {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -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, cursor: 'pointer',
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>
@@ -161,6 +161,7 @@ function CapabilityMapViewInner({
161
161
  maturityColours,
162
162
  readOnly,
163
163
  isDropTarget: hoveredId === n.id,
164
+ hasChildren: n.hasChildren,
164
165
  hasHiddenChildren: n.hasHiddenChildren,
165
166
  isCollapsed: collapsedIds.has(n.id),
166
167
  onAddChild,
@@ -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))
@@ -64,6 +64,7 @@ export interface LaidOutNode {
64
64
  width: number;
65
65
  height: number;
66
66
  data: Capability | null;
67
+ hasChildren: boolean;
67
68
  hasHiddenChildren: boolean;
68
69
  }
69
70
 
@@ -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, cursor: 'pointer',
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>
@@ -179,6 +179,7 @@ function GoalTreeViewInner({
179
179
  theme,
180
180
  readOnly,
181
181
  isDropTarget: hoveredId === n.id,
182
+ hasChildren: n.hasChildren,
182
183
  hasHiddenChildren: n.hasHiddenChildren,
183
184
  isCollapsed: collapsedIds.has(n.id),
184
185
  onAddChild,
@@ -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 childIds = (children.get(id) ?? []).filter(c => !hiddenSubtrees.has(c) && !placed.has(c));
118
- const isCollapsedRoot = hiddenSet.has(id) && childIds.length > 0;
119
- const hasHiddenChildren = childIds.length < (children.get(id) ?? []).length;
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 });
@@ -72,6 +72,8 @@ export interface LaidOutNode {
72
72
  height: number;
73
73
  data: Goal;
74
74
  isCollapsedRoot: boolean;
75
+ /** True when this node has at least one child in the source tree (visible or hidden). */
76
+ hasChildren: boolean;
75
77
  hasHiddenChildren: boolean;
76
78
  }
77
79