hoi4-agent-tools 2.3.11 → 2.3.13
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/CHANGELOG.md +12 -0
- package/dist/hoi4_agent_tools/focus/compact.js +147 -164
- package/dist/hoi4_agent_tools/focus/layout.js +219 -29
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/focus.md +1 -1
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.3.13 - 2026-08-06
|
|
4
|
+
|
|
5
|
+
- Repacked sparse sibling fan-outs around their structural parent instead of preserving avoidable long prerequisite connectors.
|
|
6
|
+
- Moved exclusive descendant branches with their compacted sibling root while keeping shared convergence focuses anchored.
|
|
7
|
+
- Added direct branch-packing regressions and reverified the public 1,024-focus MCP rewrite workflow.
|
|
8
|
+
|
|
9
|
+
## 2.3.12 - 2026-08-05
|
|
10
|
+
|
|
11
|
+
- Made compact focus rewrites choose among preservation, row compression, linear-chain repair, sibling balancing, and combined strategies instead of forcing one aggressive transformation.
|
|
12
|
+
- Limited rewrite rejection to invalid geometry while retaining long paths, fake complexity, branch balance, and compactness as prioritized cleanup objectives.
|
|
13
|
+
- Added whole-cohort sibling refinement, collision-aware incremental placement, stable repeat rewrites, and regression coverage through the public Ireland and 1,024-focus MCP workflows.
|
|
14
|
+
|
|
3
15
|
## 2.3.11 - 2026-08-05
|
|
4
16
|
|
|
5
17
|
- Made compact focus rewrites straighten offset linear chains and reject artificial staircase or zigzag geometry.
|
|
@@ -12,26 +12,21 @@ const COMPACT_HARD_DIAGNOSTICS = new Set([
|
|
|
12
12
|
'FOCUS_LAYOUT_CONNECTOR_CROSSING_UNSATISFIED',
|
|
13
13
|
'FOCUS_LAYOUT_CONNECTOR_THROUGH_NODE',
|
|
14
14
|
'FOCUS_LAYOUT_COORDINATE_CONFLICT',
|
|
15
|
-
'FOCUS_LAYOUT_LINEAR_DETOUR',
|
|
16
15
|
'FOCUS_LAYOUT_LANE_BOUNDS_UNSATISFIED',
|
|
17
16
|
'FOCUS_LAYOUT_MUTUAL_EXCLUSION_SPACING_UNSATISFIED',
|
|
17
|
+
'FOCUS_LAYOUT_PARENT_NOT_ABOVE',
|
|
18
18
|
'FOCUS_LAYOUT_PARENT_ORDER_UNSATISFIED',
|
|
19
19
|
'FOCUS_LAYOUT_SAME_ROW_SPACING_UNSATISFIED',
|
|
20
|
-
'FOCUS_LAYOUT_STAIRCASE_CHAIN',
|
|
21
20
|
'FOCUS_LAYOUT_VISIBLE_OVERLAP',
|
|
21
|
+
]);
|
|
22
|
+
const COMPACT_AESTHETIC_DIAGNOSTICS = new Set([
|
|
23
|
+
'FOCUS_LAYOUT_LINEAR_DETOUR',
|
|
24
|
+
'FOCUS_LAYOUT_LONG_CONNECTOR',
|
|
25
|
+
'FOCUS_LAYOUT_SIBLING_ANCHOR_DEVIATION',
|
|
26
|
+
'FOCUS_LAYOUT_SIBLING_ASYMMETRY',
|
|
27
|
+
'FOCUS_LAYOUT_STAIRCASE_CHAIN',
|
|
22
28
|
'FOCUS_LAYOUT_ZIGZAG_CHAIN',
|
|
23
29
|
]);
|
|
24
|
-
function compactLimits(focusCount) {
|
|
25
|
-
const graphScale = Math.sqrt(Math.max(1, focusCount));
|
|
26
|
-
const maximumHorizontalSpan = Math.max(14, Math.ceil(graphScale * 3));
|
|
27
|
-
const maximumVerticalSpan = Math.max(4, Math.ceil(Math.log2(Math.max(2, focusCount))));
|
|
28
|
-
return {
|
|
29
|
-
maximumColumns: Math.max(16, Math.ceil(graphScale * 5)),
|
|
30
|
-
maximumHorizontalSpan,
|
|
31
|
-
maximumVerticalSpan,
|
|
32
|
-
maximumManhattanSpan: maximumHorizontalSpan + maximumVerticalSpan,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
30
|
function requiredMetrics(layout) {
|
|
36
31
|
if (layout.metrics !== undefined)
|
|
37
32
|
return layout.metrics;
|
|
@@ -39,7 +34,6 @@ function requiredMetrics(layout) {
|
|
|
39
34
|
}
|
|
40
35
|
function absoluteCompactRegressions(layout) {
|
|
41
36
|
const metrics = requiredMetrics(layout);
|
|
42
|
-
const limits = compactLimits(layout.nodes.length);
|
|
43
37
|
return [
|
|
44
38
|
...(layout.diagnostics.some(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code))
|
|
45
39
|
? ['hardLayoutDiagnostics']
|
|
@@ -47,61 +41,9 @@ function absoluteCompactRegressions(layout) {
|
|
|
47
41
|
...(metrics.connectors.crossingCount > 0 ? ['connectorCrossingCount'] : []),
|
|
48
42
|
...(metrics.connectors.nodeIntersectionCount > 0 ? ['connectorNodeIntersections'] : []),
|
|
49
43
|
...(metrics.spacing.tooCloseSameRowPairCount > 0 ? ['sameRowSpacing'] : []),
|
|
50
|
-
...(metrics.symmetry.asymmetricSiblingCohortCount > 0 ? ['siblingMirrorDeviation'] : []),
|
|
51
|
-
...(metrics.symmetry.totalSiblingDeviation > 0 ? ['totalSiblingMirrorDeviation'] : []),
|
|
52
|
-
...(metrics.symmetry.offAnchorSiblingCohortCount > 0 ? ['siblingAnchorDeviation'] : []),
|
|
53
|
-
...(metrics.symmetry.totalSiblingAnchorDeviation > 0 ? ['totalSiblingAnchorDeviation'] : []),
|
|
54
44
|
...(metrics.symmetry.boundingCenterOffsetTwice > 1 ? ['boundingCenter'] : []),
|
|
55
|
-
...(metrics.bounds.columnCount > limits.maximumColumns ? ['columnBudget'] : []),
|
|
56
|
-
...(metrics.connectors.maximumHorizontalSpan > limits.maximumHorizontalSpan
|
|
57
|
-
? ['maximumHorizontalConnectorBudget']
|
|
58
|
-
: []),
|
|
59
|
-
...(metrics.connectors.maximumVerticalSpan > limits.maximumVerticalSpan
|
|
60
|
-
? ['maximumVerticalConnectorBudget']
|
|
61
|
-
: []),
|
|
62
|
-
...(metrics.connectors.maximumManhattanSpan > limits.maximumManhattanSpan
|
|
63
|
-
? ['maximumManhattanConnectorBudget']
|
|
64
|
-
: []),
|
|
65
|
-
...(metrics.connectors.longConnectorCount > 0 ? ['longConnectors'] : []),
|
|
66
45
|
];
|
|
67
46
|
}
|
|
68
|
-
function relativeCompactRegressions(current, proposed) {
|
|
69
|
-
const before = requiredMetrics(current);
|
|
70
|
-
const after = requiredMetrics(proposed);
|
|
71
|
-
return [
|
|
72
|
-
...(after.connectors.crossingCount > before.connectors.crossingCount
|
|
73
|
-
? ['connectorCrossingCount']
|
|
74
|
-
: []),
|
|
75
|
-
...(after.connectors.nodeIntersectionCount > before.connectors.nodeIntersectionCount
|
|
76
|
-
? ['connectorNodeIntersectionCount']
|
|
77
|
-
: []),
|
|
78
|
-
...(after.connectors.maximumHorizontalSpan > before.connectors.maximumHorizontalSpan
|
|
79
|
-
? ['maximumHorizontalConnectorSpan']
|
|
80
|
-
: []),
|
|
81
|
-
...(after.connectors.maximumVerticalSpan > before.connectors.maximumVerticalSpan
|
|
82
|
-
? ['maximumVerticalConnectorSpan']
|
|
83
|
-
: []),
|
|
84
|
-
...(after.connectors.maximumManhattanSpan > before.connectors.maximumManhattanSpan
|
|
85
|
-
? ['maximumManhattanConnectorSpan']
|
|
86
|
-
: []),
|
|
87
|
-
...(after.connectors.longConnectorCount > before.connectors.longConnectorCount
|
|
88
|
-
? ['longConnectorCount']
|
|
89
|
-
: []),
|
|
90
|
-
...(after.connectors.totalHorizontalSpan > before.connectors.totalHorizontalSpan
|
|
91
|
-
? ['totalHorizontalConnectorSpan']
|
|
92
|
-
: []),
|
|
93
|
-
...(after.connectors.totalManhattanSpan > before.connectors.totalManhattanSpan
|
|
94
|
-
? ['totalManhattanConnectorSpan']
|
|
95
|
-
: []),
|
|
96
|
-
...(after.bounds.columnCount > before.bounds.columnCount ? ['columnCount'] : []),
|
|
97
|
-
...(after.bounds.rowCount > before.bounds.rowCount ? ['rowCount'] : []),
|
|
98
|
-
];
|
|
99
|
-
}
|
|
100
|
-
function compactShape(plan, layout) {
|
|
101
|
-
return (plan.focuses.every(({ position }) => position.mode === 'auto') &&
|
|
102
|
-
plan.laneGroups.every(({ minimumX, maximumX }) => minimumX === undefined && maximumX === undefined) &&
|
|
103
|
-
absoluteCompactRegressions(layout).length === 0);
|
|
104
|
-
}
|
|
105
47
|
function prerequisiteIds(plan, focusId) {
|
|
106
48
|
const focus = plan.focuses.find(({ id }) => id === focusId);
|
|
107
49
|
return (focus?.prerequisites.groups
|
|
@@ -151,8 +93,14 @@ function compactStructuralAnchorX(plan, focus, parents, preferredX, laneBases) {
|
|
|
151
93
|
function mirrorSiblingCohorts(plan, preferredX, preferredY) {
|
|
152
94
|
const cohorts = new Map();
|
|
153
95
|
const laneBases = compactLaneBases(plan);
|
|
96
|
+
const childIds = new Map();
|
|
154
97
|
for (const focus of plan.focuses) {
|
|
155
98
|
const parents = prerequisiteIds(plan, focus.id);
|
|
99
|
+
for (const parentId of parents) {
|
|
100
|
+
const children = childIds.get(parentId) ?? [];
|
|
101
|
+
children.push(focus.id);
|
|
102
|
+
childIds.set(parentId, children);
|
|
103
|
+
}
|
|
156
104
|
const key = JSON.stringify([
|
|
157
105
|
parents.length === 0 ? focusLaneId(plan, focus) : null,
|
|
158
106
|
parents,
|
|
@@ -166,36 +114,44 @@ function mirrorSiblingCohorts(plan, preferredX, preferredY) {
|
|
|
166
114
|
if (cohort.length < 2)
|
|
167
115
|
continue;
|
|
168
116
|
cohort.sort((left, right) => (preferredX.get(left) ?? 0) - (preferredX.get(right) ?? 0) || compareCodeUnits(left, right));
|
|
169
|
-
const coordinates = cohort.map((id) => preferredX.get(id) ?? 0);
|
|
170
|
-
const pairCount = Math.floor(coordinates.length / 2);
|
|
171
117
|
const focus = plan.focuses.find(({ id }) => id === cohort[0]);
|
|
172
118
|
if (focus === undefined)
|
|
173
119
|
continue;
|
|
174
|
-
const mirroredAt = (centerTwice) => {
|
|
175
|
-
const mirrored = [...coordinates];
|
|
176
|
-
for (let index = 0; index < pairCount; index += 1) {
|
|
177
|
-
const opposite = coordinates.length - 1 - index;
|
|
178
|
-
const left = Math.round(((coordinates[index] ?? 0) + centerTwice - (coordinates[opposite] ?? 0)) / 2);
|
|
179
|
-
mirrored[index] = left;
|
|
180
|
-
mirrored[opposite] = centerTwice - left;
|
|
181
|
-
}
|
|
182
|
-
if (coordinates.length % 2 === 1)
|
|
183
|
-
mirrored[pairCount] = Math.round(centerTwice / 2);
|
|
184
|
-
let maximumLeft = centerTwice / 2 - (coordinates.length % 2 === 1 ? 2 : 1);
|
|
185
|
-
for (let index = pairCount - 1; index >= 0; index -= 1) {
|
|
186
|
-
const opposite = coordinates.length - 1 - index;
|
|
187
|
-
const left = Math.min(mirrored[index] ?? maximumLeft, maximumLeft);
|
|
188
|
-
mirrored[index] = left;
|
|
189
|
-
mirrored[opposite] = centerTwice - left;
|
|
190
|
-
maximumLeft = left - COMPACT_SAME_ROW_SPACING;
|
|
191
|
-
}
|
|
192
|
-
return mirrored;
|
|
193
|
-
};
|
|
194
120
|
const anchorCenterTwice = 2 *
|
|
195
121
|
compactStructuralAnchorX(plan, focus, prerequisiteIds(plan, focus.id), preferredX, laneBases);
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
122
|
+
const firstX = Math.round(anchorCenterTwice / 2 - ((cohort.length - 1) * COMPACT_SAME_ROW_SPACING) / 2);
|
|
123
|
+
const targetX = new Map(cohort.map((focusId, index) => [focusId, firstX + index * COMPACT_SAME_ROW_SPACING]));
|
|
124
|
+
const deltaX = new Map(cohort.map((focusId) => [
|
|
125
|
+
focusId,
|
|
126
|
+
(targetX.get(focusId) ?? 0) - (preferredX.get(focusId) ?? 0),
|
|
127
|
+
]));
|
|
128
|
+
const reachableBy = new Map();
|
|
129
|
+
for (const rootId of cohort) {
|
|
130
|
+
const pending = [rootId];
|
|
131
|
+
const visited = new Set();
|
|
132
|
+
while (pending.length > 0) {
|
|
133
|
+
const focusId = pending.pop();
|
|
134
|
+
if (focusId === undefined || visited.has(focusId))
|
|
135
|
+
continue;
|
|
136
|
+
visited.add(focusId);
|
|
137
|
+
const owners = reachableBy.get(focusId) ?? new Set();
|
|
138
|
+
owners.add(rootId);
|
|
139
|
+
reachableBy.set(focusId, owners);
|
|
140
|
+
pending.push(...(childIds.get(focusId) ?? []));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const [focusId, owners] of reachableBy) {
|
|
144
|
+
if (owners.size !== 1)
|
|
145
|
+
continue;
|
|
146
|
+
const ownerId = [...owners][0];
|
|
147
|
+
if (ownerId === undefined)
|
|
148
|
+
continue;
|
|
149
|
+
const delta = deltaX.get(ownerId);
|
|
150
|
+
const current = preferredX.get(focusId);
|
|
151
|
+
if (delta === undefined || current === undefined)
|
|
152
|
+
continue;
|
|
153
|
+
preferredX.set(focusId, current + delta);
|
|
154
|
+
}
|
|
199
155
|
}
|
|
200
156
|
}
|
|
201
157
|
function straightenLinearChains(plan, preferredX, preferredY) {
|
|
@@ -225,7 +181,8 @@ function straightenLinearChains(plan, preferredX, preferredY) {
|
|
|
225
181
|
preferredY.set(focus.id, parentY + 1);
|
|
226
182
|
}
|
|
227
183
|
}
|
|
228
|
-
function compactCandidate(plan, layout,
|
|
184
|
+
function compactCandidate(plan, layout, strategy) {
|
|
185
|
+
const { scale, compressRows, mirrorSiblings, straightenChains } = strategy;
|
|
229
186
|
const minimumX = Math.min(...layout.nodes.map(({ x }) => x));
|
|
230
187
|
const maximumX = Math.max(...layout.nodes.map(({ x }) => x));
|
|
231
188
|
const minimumY = Math.min(...layout.nodes.map(({ y }) => y));
|
|
@@ -256,8 +213,10 @@ function compactCandidate(plan, layout, scale, compressRows) {
|
|
|
256
213
|
for (const [index, node] of row.entries())
|
|
257
214
|
preferredX.set(node.id, (placed[index] ?? 0) + drift);
|
|
258
215
|
}
|
|
259
|
-
|
|
260
|
-
|
|
216
|
+
if (mirrorSiblings)
|
|
217
|
+
mirrorSiblingCohorts(plan, preferredX, preferredY);
|
|
218
|
+
if (straightenChains)
|
|
219
|
+
straightenLinearChains(plan, preferredX, preferredY);
|
|
261
220
|
const compacted = structuredClone(plan);
|
|
262
221
|
compacted.laneGroups = compacted.laneGroups.map(({ id, label, order }) => ({
|
|
263
222
|
id,
|
|
@@ -277,14 +236,24 @@ function compactCandidate(plan, layout, scale, compressRows) {
|
|
|
277
236
|
}
|
|
278
237
|
function compactScore(layout) {
|
|
279
238
|
const metrics = requiredMetrics(layout);
|
|
239
|
+
const hardDiagnosticCount = layout.diagnostics.filter(({ code }) => COMPACT_HARD_DIAGNOSTICS.has(code)).length;
|
|
240
|
+
const aestheticDiagnosticCount = layout.diagnostics.filter(({ code }) => COMPACT_AESTHETIC_DIAGNOSTICS.has(code)).length;
|
|
280
241
|
return [
|
|
242
|
+
hardDiagnosticCount,
|
|
243
|
+
metrics.connectors.crossingCount,
|
|
244
|
+
metrics.connectors.nodeIntersectionCount,
|
|
245
|
+
metrics.spacing.tooCloseSameRowPairCount,
|
|
246
|
+
aestheticDiagnosticCount,
|
|
281
247
|
metrics.connectors.longConnectorCount,
|
|
282
|
-
metrics.
|
|
248
|
+
metrics.symmetry.asymmetricSiblingCohortCount,
|
|
249
|
+
metrics.symmetry.offAnchorSiblingCohortCount,
|
|
250
|
+
metrics.symmetry.totalSiblingDeviation,
|
|
251
|
+
metrics.symmetry.totalSiblingAnchorDeviation,
|
|
252
|
+
metrics.bounds.rowCount,
|
|
283
253
|
metrics.bounds.columnCount,
|
|
284
|
-
metrics.connectors.
|
|
254
|
+
metrics.connectors.maximumHorizontalSpan,
|
|
285
255
|
metrics.connectors.totalHorizontalSpan,
|
|
286
256
|
metrics.connectors.maximumManhattanSpan,
|
|
287
|
-
metrics.bounds.rowCount,
|
|
288
257
|
metrics.connectors.totalVerticalSpan,
|
|
289
258
|
layout.layoutHash,
|
|
290
259
|
];
|
|
@@ -310,44 +279,60 @@ function compactScales(focusCount) {
|
|
|
310
279
|
? [0.55, 0.65, 0.75]
|
|
311
280
|
: [0.65];
|
|
312
281
|
}
|
|
282
|
+
function compactStrategies(focusCount) {
|
|
283
|
+
const preservationStrategies = [
|
|
284
|
+
{ scale: 1, compressRows: false, mirrorSiblings: false, straightenChains: false },
|
|
285
|
+
{ scale: 1, compressRows: true, mirrorSiblings: false, straightenChains: false },
|
|
286
|
+
{ scale: 1, compressRows: false, mirrorSiblings: false, straightenChains: true },
|
|
287
|
+
{ scale: 1, compressRows: false, mirrorSiblings: true, straightenChains: false },
|
|
288
|
+
];
|
|
289
|
+
const compactStrategies = compactScales(focusCount).flatMap((scale) => [
|
|
290
|
+
{ scale, compressRows: false, mirrorSiblings: true, straightenChains: true },
|
|
291
|
+
{ scale, compressRows: true, mirrorSiblings: true, straightenChains: true },
|
|
292
|
+
]);
|
|
293
|
+
return [...preservationStrategies, ...compactStrategies];
|
|
294
|
+
}
|
|
295
|
+
function needsPresentationNormalization(plan) {
|
|
296
|
+
return (plan.focuses.some(({ position }) => position.mode !== 'auto') ||
|
|
297
|
+
plan.laneGroups.some(({ minimumX, maximumX }) => minimumX !== undefined || maximumX !== undefined));
|
|
298
|
+
}
|
|
313
299
|
/**
|
|
314
300
|
* Produces a gameplay-neutral compact-reflow plan. Several deterministic
|
|
315
|
-
* compression candidates are measured with the same layout engine
|
|
316
|
-
*
|
|
317
|
-
* stays within absolute and source-relative connector budgets can win.
|
|
301
|
+
* repair and compression candidates are measured with the same layout engine.
|
|
302
|
+
* Invalid geometry cannot win; aesthetic tradeoffs are resolved by the score.
|
|
318
303
|
*/
|
|
319
304
|
export function compactFocusTreePlan(plan) {
|
|
320
|
-
const currentLayout = layoutFocusTree(plan);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
305
|
+
const currentLayout = layoutFocusTree(plan, { aggressiveAestheticRepair: true });
|
|
306
|
+
const normalizedPlan = needsPresentationNormalization(plan)
|
|
307
|
+
? compactCandidate(plan, currentLayout, {
|
|
308
|
+
scale: 1,
|
|
309
|
+
compressRows: false,
|
|
310
|
+
mirrorSiblings: false,
|
|
311
|
+
straightenChains: false,
|
|
312
|
+
})
|
|
313
|
+
: plan;
|
|
314
|
+
const normalizedLayout = normalizedPlan === plan
|
|
315
|
+
? currentLayout
|
|
316
|
+
: layoutFocusTree(normalizedPlan, { aggressiveAestheticRepair: true });
|
|
327
317
|
let selected;
|
|
328
318
|
let fallback;
|
|
329
|
-
for (const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED')
|
|
338
|
-
continue;
|
|
339
|
-
throw error;
|
|
340
|
-
}
|
|
341
|
-
if (fallback === undefined ||
|
|
342
|
-
betterScore(compactScore(layout), compactScore(fallback.layout)))
|
|
343
|
-
fallback = { plan: candidate, layout };
|
|
344
|
-
if (absoluteCompactRegressions(layout).length > 0 ||
|
|
345
|
-
relativeCompactRegressions(currentLayout, layout).length > 0)
|
|
319
|
+
for (const strategy of compactStrategies(plan.focuses.length)) {
|
|
320
|
+
const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy);
|
|
321
|
+
let layout;
|
|
322
|
+
try {
|
|
323
|
+
layout = layoutFocusTree(candidate, { aggressiveAestheticRepair: true });
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED')
|
|
346
327
|
continue;
|
|
347
|
-
|
|
348
|
-
betterScore(compactScore(layout), compactScore(selected.layout)))
|
|
349
|
-
selected = { plan: candidate, layout };
|
|
328
|
+
throw error;
|
|
350
329
|
}
|
|
330
|
+
if (fallback === undefined || betterScore(compactScore(layout), compactScore(fallback.layout)))
|
|
331
|
+
fallback = { plan: candidate, layout };
|
|
332
|
+
if (absoluteCompactRegressions(layout).length > 0)
|
|
333
|
+
continue;
|
|
334
|
+
if (selected === undefined || betterScore(compactScore(layout), compactScore(selected.layout)))
|
|
335
|
+
selected = { plan: candidate, layout };
|
|
351
336
|
}
|
|
352
337
|
if (selected !== undefined)
|
|
353
338
|
return selected.plan;
|
|
@@ -365,42 +350,44 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
|
|
|
365
350
|
const workBudget = new FocusLayoutWorkBudget(options.maximumWork ?? COMPACT_LAYOUT_WORK_MAX);
|
|
366
351
|
const layoutOptions = {
|
|
367
352
|
workBudget,
|
|
353
|
+
aggressiveAestheticRepair: true,
|
|
368
354
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
369
355
|
};
|
|
370
356
|
const currentLayout = await layoutFocusTreeAsync(plan, layoutOptions);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
357
|
+
const normalizedPlan = needsPresentationNormalization(plan)
|
|
358
|
+
? compactCandidate(plan, currentLayout, {
|
|
359
|
+
scale: 1,
|
|
360
|
+
compressRows: false,
|
|
361
|
+
mirrorSiblings: false,
|
|
362
|
+
straightenChains: false,
|
|
363
|
+
})
|
|
364
|
+
: plan;
|
|
365
|
+
const normalizedLayout = normalizedPlan === plan
|
|
366
|
+
? currentLayout
|
|
367
|
+
: await layoutFocusTreeAsync(normalizedPlan, layoutOptions);
|
|
376
368
|
let selected;
|
|
377
369
|
let fallback;
|
|
378
370
|
let exhausted;
|
|
379
|
-
candidateSearch: for (const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
break candidateSearch;
|
|
391
|
-
}
|
|
392
|
-
throw error;
|
|
371
|
+
candidateSearch: for (const strategy of compactStrategies(plan.focuses.length)) {
|
|
372
|
+
options.signal?.throwIfAborted();
|
|
373
|
+
const candidate = compactCandidate(normalizedPlan, normalizedLayout, strategy);
|
|
374
|
+
let layout;
|
|
375
|
+
try {
|
|
376
|
+
layout = await layoutFocusTreeAsync(candidate, layoutOptions);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (error instanceof ServiceError && error.code === 'FOCUS_LAYOUT_WORK_BUDGET_BLOCKED') {
|
|
380
|
+
exhausted = error;
|
|
381
|
+
break candidateSearch;
|
|
393
382
|
}
|
|
394
|
-
|
|
395
|
-
betterScore(compactScore(layout), compactScore(fallback.layout)))
|
|
396
|
-
fallback = { plan: candidate, layout };
|
|
397
|
-
if (absoluteCompactRegressions(layout).length > 0 ||
|
|
398
|
-
relativeCompactRegressions(currentLayout, layout).length > 0)
|
|
399
|
-
continue;
|
|
400
|
-
if (selected === undefined ||
|
|
401
|
-
betterScore(compactScore(layout), compactScore(selected.layout)))
|
|
402
|
-
selected = { plan: candidate, layout };
|
|
383
|
+
throw error;
|
|
403
384
|
}
|
|
385
|
+
if (fallback === undefined || betterScore(compactScore(layout), compactScore(fallback.layout)))
|
|
386
|
+
fallback = { plan: candidate, layout };
|
|
387
|
+
if (absoluteCompactRegressions(layout).length > 0)
|
|
388
|
+
continue;
|
|
389
|
+
if (selected === undefined || betterScore(compactScore(layout), compactScore(selected.layout)))
|
|
390
|
+
selected = { plan: candidate, layout };
|
|
404
391
|
}
|
|
405
392
|
if (selected !== undefined) {
|
|
406
393
|
return { plan: selected.plan, currentLayout, proposedLayout: selected.layout };
|
|
@@ -413,19 +400,15 @@ export async function compactFocusTreePlanAsync(plan, options = {}) {
|
|
|
413
400
|
}
|
|
414
401
|
throw new ServiceError('FOCUS_COMPACT_LAYOUT_REQUIRED', 'Compact focus planning did not produce a measurable layout candidate', { treeId: plan.id });
|
|
415
402
|
}
|
|
416
|
-
/** Refuses compact rewrites that
|
|
417
|
-
export function assertCompactLayoutQuality(
|
|
418
|
-
const regressions = [
|
|
419
|
-
...absoluteCompactRegressions(proposed),
|
|
420
|
-
...(current === undefined ? [] : relativeCompactRegressions(current, proposed)),
|
|
421
|
-
].filter((value, index, all) => all.indexOf(value) === index);
|
|
403
|
+
/** Refuses compact rewrites that still contain invalid or unreadable geometry. */
|
|
404
|
+
export function assertCompactLayoutQuality(_current, proposed) {
|
|
405
|
+
const regressions = [...absoluteCompactRegressions(proposed)].filter((value, index, all) => all.indexOf(value) === index);
|
|
422
406
|
if (regressions.length === 0)
|
|
423
407
|
return;
|
|
424
|
-
throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the
|
|
408
|
+
throw new ServiceError('FOCUS_COMPACT_QUALITY_BLOCKED', 'Compact focus reflow failed the layout-correctness gate', {
|
|
425
409
|
treeId: proposed.treeId,
|
|
426
410
|
regressions,
|
|
427
|
-
|
|
428
|
-
...(current === undefined ? {} : { before: requiredMetrics(current) }),
|
|
411
|
+
...(_current === undefined ? {} : { before: requiredMetrics(_current) }),
|
|
429
412
|
proposed: requiredMetrics(proposed),
|
|
430
413
|
});
|
|
431
414
|
}
|
|
@@ -1258,26 +1258,60 @@ function longConnectorSpans(spans) {
|
|
|
1258
1258
|
function longConnector(edge) {
|
|
1259
1259
|
return longConnectorSpans(connectorSpans(edge));
|
|
1260
1260
|
}
|
|
1261
|
-
function
|
|
1261
|
+
function linearDetourCount(edges) {
|
|
1262
|
+
const incomingCount = new Map();
|
|
1263
|
+
const outgoingCount = new Map();
|
|
1264
|
+
for (const edge of edges) {
|
|
1265
|
+
incomingCount.set(edge.childId, (incomingCount.get(edge.childId) ?? 0) + 1);
|
|
1266
|
+
outgoingCount.set(edge.parentId, (outgoingCount.get(edge.parentId) ?? 0) + 1);
|
|
1267
|
+
}
|
|
1268
|
+
return edges.filter((edge) => {
|
|
1269
|
+
if ((outgoingCount.get(edge.parentId) ?? 0) !== 1 ||
|
|
1270
|
+
(incomingCount.get(edge.childId) ?? 0) !== 1)
|
|
1271
|
+
return false;
|
|
1272
|
+
const spans = connectorSpans(edge);
|
|
1273
|
+
return spans.horizontal !== 0 || spans.vertical !== 1;
|
|
1274
|
+
}).length;
|
|
1275
|
+
}
|
|
1276
|
+
function betterConnectorQualityScore(candidate, current, aggressiveAestheticRepair = false) {
|
|
1262
1277
|
if (candidate.longConnectorCount > current.longConnectorCount)
|
|
1263
1278
|
return false;
|
|
1264
|
-
const fields =
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1279
|
+
const fields = aggressiveAestheticRepair
|
|
1280
|
+
? [
|
|
1281
|
+
'crossingCount',
|
|
1282
|
+
'nodeIntersectionCount',
|
|
1283
|
+
'longConnectorCount',
|
|
1284
|
+
'linearDetourCount',
|
|
1285
|
+
'asymmetricSiblingCohortCount',
|
|
1286
|
+
'maximumSiblingDeviation',
|
|
1287
|
+
'totalSiblingDeviation',
|
|
1288
|
+
'offAnchorSiblingCohortCount',
|
|
1289
|
+
'maximumSiblingAnchorDeviation',
|
|
1290
|
+
'totalSiblingAnchorDeviation',
|
|
1291
|
+
'maximumManhattanSpan',
|
|
1292
|
+
'maximumHorizontalSpan',
|
|
1293
|
+
'totalManhattanSpan',
|
|
1294
|
+
'totalHorizontalSpan',
|
|
1295
|
+
'columnCount',
|
|
1296
|
+
'boundingCenterOffsetTwice',
|
|
1297
|
+
]
|
|
1298
|
+
: [
|
|
1299
|
+
'crossingCount',
|
|
1300
|
+
'nodeIntersectionCount',
|
|
1301
|
+
'maximumManhattanSpan',
|
|
1302
|
+
'maximumHorizontalSpan',
|
|
1303
|
+
'longConnectorCount',
|
|
1304
|
+
'totalManhattanSpan',
|
|
1305
|
+
'totalHorizontalSpan',
|
|
1306
|
+
'asymmetricSiblingCohortCount',
|
|
1307
|
+
'maximumSiblingDeviation',
|
|
1308
|
+
'totalSiblingDeviation',
|
|
1309
|
+
'offAnchorSiblingCohortCount',
|
|
1310
|
+
'maximumSiblingAnchorDeviation',
|
|
1311
|
+
'totalSiblingAnchorDeviation',
|
|
1312
|
+
'columnCount',
|
|
1313
|
+
'boundingCenterOffsetTwice',
|
|
1314
|
+
];
|
|
1281
1315
|
for (const field of fields) {
|
|
1282
1316
|
if (candidate[field] === current[field])
|
|
1283
1317
|
continue;
|
|
@@ -1324,8 +1358,10 @@ function* affectedNodeIntersectionCount(context, edges, focusId, x) {
|
|
|
1324
1358
|
}
|
|
1325
1359
|
return count;
|
|
1326
1360
|
}
|
|
1327
|
-
function* connectorNodeIntersectionSummary(context, edges) {
|
|
1328
|
-
const nodes = [...context.placed.values()]
|
|
1361
|
+
function* connectorNodeIntersectionSummary(context, edges, override) {
|
|
1362
|
+
const nodes = [...context.placed.values()]
|
|
1363
|
+
.map((node) => ({ ...node, x: override?.get(node.id) ?? node.x }))
|
|
1364
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
1329
1365
|
const involvedFocusIds = new Set();
|
|
1330
1366
|
let count = 0;
|
|
1331
1367
|
let comparisonIndex = 0;
|
|
@@ -1422,14 +1458,18 @@ function siblingQuality(context, override) {
|
|
|
1422
1458
|
parentFocusIds,
|
|
1423
1459
|
};
|
|
1424
1460
|
}
|
|
1425
|
-
function connectorQualityScore(context, edges, crossingCount, nodeIntersectionCount, symmetry) {
|
|
1461
|
+
function connectorQualityScore(context, edges, crossingCount, nodeIntersectionCount, symmetry, override) {
|
|
1426
1462
|
const spans = edges.map(connectorSpans);
|
|
1427
|
-
const nodes = [...context.placed.values()]
|
|
1463
|
+
const nodes = [...context.placed.values()].map((node) => ({
|
|
1464
|
+
...node,
|
|
1465
|
+
x: override?.get(node.id) ?? node.x,
|
|
1466
|
+
}));
|
|
1428
1467
|
const minimumX = Math.min(...nodes.map(({ x }) => x));
|
|
1429
1468
|
const maximumX = Math.max(...nodes.map(({ x }) => x));
|
|
1430
1469
|
return {
|
|
1431
1470
|
crossingCount,
|
|
1432
1471
|
nodeIntersectionCount,
|
|
1472
|
+
linearDetourCount: linearDetourCount(edges),
|
|
1433
1473
|
asymmetricSiblingCohortCount: symmetry.asymmetricSiblingCohortCount,
|
|
1434
1474
|
maximumSiblingDeviation: symmetry.maximumSiblingDeviation,
|
|
1435
1475
|
totalSiblingDeviation: symmetry.totalSiblingDeviation,
|
|
@@ -1445,6 +1485,115 @@ function connectorQualityScore(context, edges, crossingCount, nodeIntersectionCo
|
|
|
1445
1485
|
boundingCenterOffsetTwice: Math.abs(minimumX + maximumX),
|
|
1446
1486
|
};
|
|
1447
1487
|
}
|
|
1488
|
+
function edgesWithXOverride(edges, override) {
|
|
1489
|
+
return edges.map((edge) => ({
|
|
1490
|
+
...edge,
|
|
1491
|
+
parent: { ...edge.parent, x: override.get(edge.parentId) ?? edge.parent.x },
|
|
1492
|
+
child: { ...edge.child, x: override.get(edge.childId) ?? edge.child.x },
|
|
1493
|
+
}));
|
|
1494
|
+
}
|
|
1495
|
+
function siblingBatchConflicts(context, cohort, override) {
|
|
1496
|
+
const moved = [...override].flatMap(([focusId, x]) => {
|
|
1497
|
+
const node = context.placed.get(focusId);
|
|
1498
|
+
return node === undefined ? [] : [{ ...node, x }];
|
|
1499
|
+
});
|
|
1500
|
+
for (const node of moved) {
|
|
1501
|
+
if (!coordinateWithinLane(context, node.id, node.x))
|
|
1502
|
+
return true;
|
|
1503
|
+
for (const other of context.placed.values()) {
|
|
1504
|
+
if (cohort.has(other.id) || other.y !== node.y)
|
|
1505
|
+
continue;
|
|
1506
|
+
if (Math.abs(other.x - node.x) < context.nodeSpacing)
|
|
1507
|
+
return true;
|
|
1508
|
+
}
|
|
1509
|
+
for (const excludedId of context.mutualExclusions.get(node.id) ?? []) {
|
|
1510
|
+
const other = context.placed.get(excludedId);
|
|
1511
|
+
if (other === undefined)
|
|
1512
|
+
continue;
|
|
1513
|
+
const otherX = override.get(excludedId) ?? other.x;
|
|
1514
|
+
if (Math.abs(otherX - node.x) < context.minimumMutualExclusionSpacing)
|
|
1515
|
+
return true;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const ordered = [...moved].sort((left, right) => left.y - right.y || left.x - right.x || compareCodeUnits(left.id, right.id));
|
|
1519
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
1520
|
+
const left = ordered[index - 1];
|
|
1521
|
+
const right = ordered[index];
|
|
1522
|
+
if (left?.y === right?.y &&
|
|
1523
|
+
left !== undefined &&
|
|
1524
|
+
right !== undefined &&
|
|
1525
|
+
Math.abs(right.x - left.x) < context.nodeSpacing)
|
|
1526
|
+
return true;
|
|
1527
|
+
}
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
function* refineSiblingCohorts(context, edges, initial) {
|
|
1531
|
+
let current = initial;
|
|
1532
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
1533
|
+
let accepted = false;
|
|
1534
|
+
for (const [cohortIndex, cohort] of context.siblingCohorts.entries()) {
|
|
1535
|
+
yield* cancellationCheckpoint(context.signal, cohortIndex, 8);
|
|
1536
|
+
if (cohort.some((focusId) => !canMoveForSoftConstraint(context, focusId)))
|
|
1537
|
+
continue;
|
|
1538
|
+
const nodes = cohort
|
|
1539
|
+
.flatMap((focusId) => {
|
|
1540
|
+
const node = context.placed.get(focusId);
|
|
1541
|
+
return node === undefined ? [] : [node];
|
|
1542
|
+
})
|
|
1543
|
+
.sort((left, right) => left.x - right.x || compareCodeUnits(left.id, right.id));
|
|
1544
|
+
if (nodes.length < 2)
|
|
1545
|
+
continue;
|
|
1546
|
+
const { anchorX } = siblingCohortAnchor(context, cohort);
|
|
1547
|
+
const currentSpan = (nodes.at(-1)?.x ?? 0) - (nodes[0]?.x ?? 0);
|
|
1548
|
+
const spacing = Math.max(context.nodeSpacing, Math.ceil(currentSpan / Math.max(1, nodes.length - 1)));
|
|
1549
|
+
const cohortIds = new Set(cohort);
|
|
1550
|
+
const internalCenter = ((nodes[0]?.x ?? 0) + (nodes.at(-1)?.x ?? 0)) / 2;
|
|
1551
|
+
let best;
|
|
1552
|
+
for (const center of [...new Set([internalCenter, anchorX])]) {
|
|
1553
|
+
const override = new Map();
|
|
1554
|
+
for (const [index, node] of nodes.entries()) {
|
|
1555
|
+
override.set(node.id, Math.round(center + (index - (nodes.length - 1) / 2) * spacing));
|
|
1556
|
+
}
|
|
1557
|
+
if (nodes.every((node) => override.get(node.id) === node.x))
|
|
1558
|
+
continue;
|
|
1559
|
+
if (siblingBatchConflicts(context, cohortIds, override))
|
|
1560
|
+
continue;
|
|
1561
|
+
const candidateEdges = edgesWithXOverride(edges, override);
|
|
1562
|
+
const crossingSummary = yield* connectorCrossings(candidateEdges, context);
|
|
1563
|
+
if (crossingSummary.count > current.crossingCount)
|
|
1564
|
+
continue;
|
|
1565
|
+
const intersectionSummary = yield* connectorNodeIntersectionSummary(context, candidateEdges, override);
|
|
1566
|
+
if (intersectionSummary.count > current.nodeIntersectionCount)
|
|
1567
|
+
continue;
|
|
1568
|
+
const symmetry = siblingQuality(context, override);
|
|
1569
|
+
const score = connectorQualityScore(context, candidateEdges, crossingSummary.count, intersectionSummary.count, symmetry, override);
|
|
1570
|
+
if (!betterConnectorQualityScore(score, current, true))
|
|
1571
|
+
continue;
|
|
1572
|
+
if (best !== undefined && !betterConnectorQualityScore(score, best.score, true))
|
|
1573
|
+
continue;
|
|
1574
|
+
best = { override, score };
|
|
1575
|
+
}
|
|
1576
|
+
if (best === undefined)
|
|
1577
|
+
continue;
|
|
1578
|
+
for (const node of nodes) {
|
|
1579
|
+
const x = best.override.get(node.id);
|
|
1580
|
+
if (x === undefined || x === node.x)
|
|
1581
|
+
continue;
|
|
1582
|
+
context.decisions.push({
|
|
1583
|
+
focusId: node.id,
|
|
1584
|
+
kind: 'moved_to_improve_connector_quality',
|
|
1585
|
+
message: `Sibling-balance refinement moved (${node.x}, ${node.y}) to (${x}, ${node.y})`,
|
|
1586
|
+
});
|
|
1587
|
+
movePlacedFocusX(context, node, x);
|
|
1588
|
+
}
|
|
1589
|
+
current = best.score;
|
|
1590
|
+
accepted = true;
|
|
1591
|
+
}
|
|
1592
|
+
if (!accepted)
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
return current;
|
|
1596
|
+
}
|
|
1448
1597
|
function candidateQualityScore(context, edges, current, focusId, x, currentAffectedCrossings, candidateAffectedCrossings, currentAffectedNodeIntersections, candidateAffectedNodeIntersections) {
|
|
1449
1598
|
const incidentEdges = edges.filter((edge) => edge.parentId === focusId || edge.childId === focusId);
|
|
1450
1599
|
const nonIncidentEdges = edges.filter((edge) => edge.parentId !== focusId && edge.childId !== focusId);
|
|
@@ -1471,6 +1620,7 @@ function candidateQualityScore(context, edges, current, focusId, x, currentAffec
|
|
|
1471
1620
|
nodeIntersectionCount: current.nodeIntersectionCount -
|
|
1472
1621
|
currentAffectedNodeIntersections +
|
|
1473
1622
|
candidateAffectedNodeIntersections,
|
|
1623
|
+
linearDetourCount: linearDetourCount([...nonIncidentEdges, ...candidateIncidentEdges]),
|
|
1474
1624
|
asymmetricSiblingCohortCount: symmetry.asymmetricSiblingCohortCount,
|
|
1475
1625
|
maximumSiblingDeviation: symmetry.maximumSiblingDeviation,
|
|
1476
1626
|
totalSiblingDeviation: symmetry.totalSiblingDeviation,
|
|
@@ -1492,29 +1642,67 @@ function candidateQualityScore(context, edges, current, focusId, x, currentAffec
|
|
|
1492
1642
|
}
|
|
1493
1643
|
const CONNECTOR_REFINEMENT_WORK_MAX = 7_000_000;
|
|
1494
1644
|
function* refineConnectorQualityWithinBudget(context) {
|
|
1495
|
-
const maximumAcceptedMoves = 24;
|
|
1496
|
-
const maximumCandidateNodes = 32;
|
|
1645
|
+
const maximumAcceptedMoves = context.aggressiveAestheticRepair ? 32 : 24;
|
|
1646
|
+
const maximumCandidateNodes = context.aggressiveAestheticRepair ? 48 : 32;
|
|
1497
1647
|
const edges = yield* connectorEdges(context);
|
|
1498
1648
|
const crossingSummary = yield* connectorCrossings(edges, context);
|
|
1499
1649
|
const intersectionSummary = yield* connectorNodeIntersectionSummary(context, edges);
|
|
1500
1650
|
const symmetry = siblingQuality(context);
|
|
1501
1651
|
let current = connectorQualityScore(context, edges, crossingSummary.count, intersectionSummary.count, symmetry);
|
|
1502
|
-
|
|
1652
|
+
if (context.aggressiveAestheticRepair)
|
|
1653
|
+
current = yield* refineSiblingCohorts(context, edges, current);
|
|
1503
1654
|
const candidateIds = new Set(intersectionSummary.involvedFocusIds);
|
|
1655
|
+
const longFocusIds = new Set();
|
|
1504
1656
|
for (const edge of edges.filter(longConnector)) {
|
|
1505
1657
|
candidateIds.add(edge.parentId);
|
|
1506
1658
|
candidateIds.add(edge.childId);
|
|
1659
|
+
longFocusIds.add(edge.parentId);
|
|
1660
|
+
longFocusIds.add(edge.childId);
|
|
1661
|
+
}
|
|
1662
|
+
const siblingFocusIds = new Set(context.siblingCohorts.flat());
|
|
1663
|
+
if (context.aggressiveAestheticRepair) {
|
|
1664
|
+
for (const cohort of context.siblingCohorts) {
|
|
1665
|
+
for (const focusId of cohort) {
|
|
1666
|
+
candidateIds.add(focusId);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1507
1669
|
}
|
|
1508
1670
|
for (const parentId of symmetry.parentFocusIds)
|
|
1509
1671
|
candidateIds.add(parentId);
|
|
1672
|
+
const incomingCount = new Map();
|
|
1673
|
+
const outgoingCount = new Map();
|
|
1674
|
+
const linearFocusIds = new Set();
|
|
1675
|
+
for (const edge of context.aggressiveAestheticRepair ? edges : []) {
|
|
1676
|
+
incomingCount.set(edge.childId, (incomingCount.get(edge.childId) ?? 0) + 1);
|
|
1677
|
+
outgoingCount.set(edge.parentId, (outgoingCount.get(edge.parentId) ?? 0) + 1);
|
|
1678
|
+
}
|
|
1679
|
+
for (const edge of edges) {
|
|
1680
|
+
if ((outgoingCount.get(edge.parentId) ?? 0) !== 1 ||
|
|
1681
|
+
(incomingCount.get(edge.childId) ?? 0) !== 1)
|
|
1682
|
+
continue;
|
|
1683
|
+
const spans = connectorSpans(edge);
|
|
1684
|
+
if (spans.horizontal === 0 && spans.vertical === 1)
|
|
1685
|
+
continue;
|
|
1686
|
+
candidateIds.add(edge.parentId);
|
|
1687
|
+
candidateIds.add(edge.childId);
|
|
1688
|
+
linearFocusIds.add(edge.parentId);
|
|
1689
|
+
linearFocusIds.add(edge.childId);
|
|
1690
|
+
}
|
|
1510
1691
|
const orderedCandidateIds = [...candidateIds]
|
|
1511
1692
|
.filter((focusId) => {
|
|
1512
1693
|
const degree = context.connectorDefinitionsByFocus.get(focusId)?.length ?? 0;
|
|
1513
|
-
return (canMoveForSoftConstraint(context, focusId) &&
|
|
1694
|
+
return (canMoveForSoftConstraint(context, focusId) &&
|
|
1695
|
+
(context.aggressiveAestheticRepair || (!siblingFocusIds.has(focusId) && degree !== 1)));
|
|
1514
1696
|
})
|
|
1515
1697
|
.sort((left, right) => {
|
|
1516
1698
|
const degree = (focusId) => context.connectorDefinitionsByFocus.get(focusId)?.length ?? 0;
|
|
1517
|
-
|
|
1699
|
+
const priority = (focusId) => (intersectionSummary.involvedFocusIds.has(focusId) ? 8 : 0) +
|
|
1700
|
+
(longFocusIds.has(focusId) ? 4 : 0) +
|
|
1701
|
+
(linearFocusIds.has(focusId) ? 2 : 0) +
|
|
1702
|
+
(siblingFocusIds.has(focusId) ? 1 : 0);
|
|
1703
|
+
return (priority(right) - priority(left) ||
|
|
1704
|
+
degree(right) - degree(left) ||
|
|
1705
|
+
compareCodeUnits(left, right));
|
|
1518
1706
|
})
|
|
1519
1707
|
.slice(0, maximumCandidateNodes);
|
|
1520
1708
|
for (let acceptedMoves = 0; acceptedMoves < maximumAcceptedMoves; acceptedMoves += 1) {
|
|
@@ -1551,9 +1739,10 @@ function* refineConnectorQualityWithinBudget(context) {
|
|
|
1551
1739
|
continue;
|
|
1552
1740
|
const candidateAffectedNodeIntersections = yield* affectedNodeIntersectionCount(context, edges, focusId, x);
|
|
1553
1741
|
const score = candidateQualityScore(context, edges, current, focusId, x, currentAffectedCrossings, candidateAffectedCrossings, currentAffectedNodeIntersections, candidateAffectedNodeIntersections);
|
|
1554
|
-
if (!betterConnectorQualityScore(score, current))
|
|
1742
|
+
if (!betterConnectorQualityScore(score, current, context.aggressiveAestheticRepair))
|
|
1555
1743
|
continue;
|
|
1556
|
-
if (best !== undefined &&
|
|
1744
|
+
if (best !== undefined &&
|
|
1745
|
+
!betterConnectorQualityScore(score, best.score, context.aggressiveAestheticRepair))
|
|
1557
1746
|
continue;
|
|
1558
1747
|
best = {
|
|
1559
1748
|
focusId,
|
|
@@ -1955,6 +2144,7 @@ function* layoutFocusTreeSteps(plan, options = {}) {
|
|
|
1955
2144
|
siblingCohorts: siblingLayout.cohorts,
|
|
1956
2145
|
nodeSpacing,
|
|
1957
2146
|
minimumMutualExclusionSpacing: nodeSpacing,
|
|
2147
|
+
aggressiveAestheticRepair: options.aggressiveAestheticRepair ?? false,
|
|
1958
2148
|
decisions: [],
|
|
1959
2149
|
diagnostics,
|
|
1960
2150
|
work: options.workBudget ?? new FocusLayoutWorkBudget(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
export const PACKAGE_NAME = 'hoi4-agent-tools';
|
|
3
|
-
export const PACKAGE_VERSION = '2.3.
|
|
3
|
+
export const PACKAGE_VERSION = '2.3.13';
|
|
4
4
|
export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
|
|
5
5
|
export const CONFIG_VERSION = 1;
|
|
6
6
|
export const TRANSACTION_VERSION = 1;
|
package/docs/focus.md
CHANGED
|
@@ -30,7 +30,7 @@ For a layout-only cleanup of an existing national tree, call `hoi4.focus_rewrite
|
|
|
30
30
|
|
|
31
31
|
Compact mode imports the tree, resets authored presentation coordinates and lane bounds, and performs a deterministic reflow in the same rewrite call. It keeps prerequisites, mutual exclusions, availability, bypasses, rewards, icons, localisation, AI behavior, and route/lane membership unchanged.
|
|
32
32
|
|
|
33
|
-
The rewrite measures canvas bounds, same-row spacing, the same curved connector paths used by the renderer, connector lengths, connector-node intersections, branch balance, overall centering, and topology-aware fake-complexity patterns. Compact mode
|
|
33
|
+
The rewrite measures canvas bounds, same-row spacing, the same curved connector paths used by the renderer, connector lengths, connector-node intersections, branch balance, overall centering, and topology-aware fake-complexity patterns. Compact mode evaluates preservation, row repair, linear-chain repair, sibling balancing, and combined compression strategies, then chooses the cleanest valid result. Sparse direct sibling fan-outs are packed around their parent at the minimum readable spacing, with each exclusive descendant branch moving as a unit and shared convergence focuses staying centered. Dense branches remain wider only when their contents need the room. It refuses invalid geometry such as overlapping or cramped focuses, bad parent order, rendered crossings, connectors through unrelated focuses, or an off-center canvas. Long connectors, fake detours, staircase or zigzag chains, and avoidable sibling imbalance are optimization priorities instead of independent rejection budgets.
|
|
34
34
|
|
|
35
35
|
Use the default `layoutMode: "authored"` with a complete plan when deliberately designing coordinates or changing gameplay. Set `layoutMode: "compact"` with a complete plan when creating a large automatically arranged tree. Inspect and render calls remain useful before or after either rewrite mode, but a separate plan is not required for compact cleanup.
|
|
36
36
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoi4-agent-tools",
|
|
3
3
|
"mcpName": "io.github.klimPaskov/hoi4-agent-tools",
|
|
4
|
-
"version": "2.3.
|
|
4
|
+
"version": "2.3.13",
|
|
5
5
|
"description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/configuration.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/continuous-focus-palette.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/custom-weighted-pool.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/event-feature-manifest.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/focus-plan.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/focus-planning-sidecar.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/gui-animation-source.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/gui-helper.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/gui-scenario.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/map-operation.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"oneOf": [
|
|
5
5
|
{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/operation-result.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-analysis-result.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-compare-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-evaluate-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-inspect-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-render-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-scenario-set.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-sequence-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-simulate-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.
|
|
2
|
+
"$id": "https://github.com/klimPaskov/hoi4-agent-tools/blob/v2.3.13/schemas/probability-sweep-input.schema.json",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"properties": {
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.klimPaskov/hoi4-agent-tools",
|
|
4
4
|
"title": "HOI4 Agent Tools",
|
|
5
5
|
"description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
|
|
6
|
-
"version": "2.3.
|
|
6
|
+
"version": "2.3.13",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/klimPaskov/hoi4-agent-tools",
|
|
9
9
|
"source": "github"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "hoi4-agent-tools",
|
|
16
|
-
"version": "2.3.
|
|
16
|
+
"version": "2.3.13",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|