hoi4-agent-tools 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +27 -16
  3. package/dist/hoi4_agent_tools/core/engine.js +12 -0
  4. package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
  5. package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
  6. package/dist/hoi4_agent_tools/event/compare.js +413 -0
  7. package/dist/hoi4_agent_tools/event/graph.js +1586 -0
  8. package/dist/hoi4_agent_tools/event/index.js +10 -0
  9. package/dist/hoi4_agent_tools/event/layout.js +218 -0
  10. package/dist/hoi4_agent_tools/event/limits.js +41 -0
  11. package/dist/hoi4_agent_tools/event/model.js +2 -0
  12. package/dist/hoi4_agent_tools/event/queries.js +643 -0
  13. package/dist/hoi4_agent_tools/event/render.js +443 -0
  14. package/dist/hoi4_agent_tools/event/scc.js +72 -0
  15. package/dist/hoi4_agent_tools/event/service.js +1147 -0
  16. package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
  17. package/dist/hoi4_agent_tools/focus/compact.js +432 -0
  18. package/dist/hoi4_agent_tools/focus/compiler.js +1 -7
  19. package/dist/hoi4_agent_tools/focus/geometry.js +203 -16
  20. package/dist/hoi4_agent_tools/focus/index.js +1 -0
  21. package/dist/hoi4_agent_tools/focus/layout.js +1256 -92
  22. package/dist/hoi4_agent_tools/focus/limits.js +1 -1
  23. package/dist/hoi4_agent_tools/focus/lint.js +11 -3
  24. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  25. package/dist/hoi4_agent_tools/focus/planning.js +6 -1
  26. package/dist/hoi4_agent_tools/focus/render.js +14 -9
  27. package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
  28. package/dist/hoi4_agent_tools/mcp/server/context-schemas.js +8 -0
  29. package/dist/hoi4_agent_tools/mcp/server/create.js +4 -1
  30. package/dist/hoi4_agent_tools/mcp/server/result.js +117 -52
  31. package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
  32. package/dist/hoi4_agent_tools/mcp/tools/focus.js +93 -7
  33. package/dist/hoi4_agent_tools/mcp/tools/gui.js +12 -9
  34. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -4
  35. package/dist/hoi4_agent_tools/mcp/tools/mods.js +1 -1
  36. package/dist/hoi4_agent_tools/schemas/event.js +95 -0
  37. package/dist/hoi4_agent_tools/schemas/focus.js +64 -8
  38. package/dist/hoi4_agent_tools/version.js +1 -1
  39. package/docs/README.md +1 -0
  40. package/docs/development.md +10 -0
  41. package/docs/events.md +113 -0
  42. package/docs/focus.md +17 -7
  43. package/docs/setup.md +3 -3
  44. package/package.json +9 -5
  45. package/server.json +3 -3
@@ -3,18 +3,50 @@ import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
3
3
  import { DiagnosticCollector } from '../core/diagnostic-collector.js';
4
4
  import { sortDiagnostics } from '../core/diagnostics.js';
5
5
  import { ServiceError } from '../core/result.js';
6
- import { focusConnectorSegmentsProperlyCross, focusNodesVisiblyOverlap } from './geometry.js';
6
+ import { flattenFocusConnectorCurve, focusConnectorCurve, focusConnectorPolylinesIntersect, focusConnectorPolylineIntersectsRectangle, focusNodeRectangle, focusNodesVisiblyOverlap, } from './geometry.js';
7
7
  import { FOCUS_GRAPH_MAX_DEPTH, FOCUS_DIAGNOSTIC_MAX, FOCUS_GRAPH_MAX_EDGES, FOCUS_GRAPH_MAX_NODES, FOCUS_LAYOUT_WORK_MAX, } from './limits.js';
8
8
  import {} from './model.js';
9
- class LayoutWorkBudget {
9
+ export class FocusLayoutWorkBudget {
10
+ maximumWork;
10
11
  used = 0;
12
+ constructor(maximumWork = FOCUS_LAYOUT_WORK_MAX) {
13
+ this.maximumWork = maximumWork;
14
+ if (!Number.isSafeInteger(maximumWork) || maximumWork <= 0) {
15
+ throw new ServiceError('FOCUS_LAYOUT_WORK_BUDGET_INVALID', 'Focus layout work budget must be a positive safe integer', { maximumWork });
16
+ }
17
+ }
18
+ get consumed() {
19
+ return this.used;
20
+ }
11
21
  spend(phase, amount = 1) {
12
- if (!Number.isSafeInteger(amount) || amount < 0 || amount > FOCUS_LAYOUT_WORK_MAX - this.used) {
13
- throw new ServiceError('FOCUS_LAYOUT_WORK_BUDGET_BLOCKED', 'Focus layout exceeds the fixed placement and comparison work ceiling', { phase, used: this.used, requested: amount, maximumWork: FOCUS_LAYOUT_WORK_MAX });
22
+ if (!Number.isSafeInteger(amount) || amount < 0 || amount > this.maximumWork - this.used) {
23
+ throw new ServiceError('FOCUS_LAYOUT_WORK_BUDGET_BLOCKED', 'Focus layout exceeds the fixed placement and comparison work ceiling', { phase, used: this.used, requested: amount, maximumWork: this.maximumWork });
14
24
  }
15
25
  this.used += amount;
16
26
  }
17
27
  }
28
+ class FocusLayoutSubBudgetExhausted extends Error {
29
+ }
30
+ class FocusLayoutSubBudget {
31
+ delegate;
32
+ maximumWork;
33
+ startedAt;
34
+ constructor(delegate, maximumWork) {
35
+ this.delegate = delegate;
36
+ this.maximumWork = maximumWork;
37
+ this.startedAt = delegate.consumed;
38
+ }
39
+ get consumed() {
40
+ return this.delegate.consumed;
41
+ }
42
+ spend(phase, amount = 1) {
43
+ if (!Number.isSafeInteger(amount) ||
44
+ amount < 0 ||
45
+ this.delegate.consumed - this.startedAt + amount > this.maximumWork)
46
+ throw new FocusLayoutSubBudgetExhausted();
47
+ this.delegate.spend(`connector refinement: ${phase}`, amount);
48
+ }
49
+ }
18
50
  function* cancellationCheckpoint(signal, iteration = 0, stride = 1) {
19
51
  if (iteration % stride !== 0)
20
52
  return;
@@ -42,6 +74,11 @@ async function completeCooperatively(steps, signal) {
42
74
  yieldDeadline = performance.now() + 8;
43
75
  }
44
76
  }
77
+ const DEFAULT_LANE_SPACING = 8;
78
+ const DEFAULT_NODE_SPACING = 2;
79
+ const LONG_CONNECTOR_HORIZONTAL_SPAN = 8;
80
+ const LONG_CONNECTOR_VERTICAL_SPAN = 4;
81
+ const LONG_CONNECTOR_MANHATTAN_SPAN = 10;
45
82
  function coordinateKey(x, y) {
46
83
  return `${x},${y}`;
47
84
  }
@@ -62,9 +99,10 @@ function laneBases(plan, discovered, spacing) {
62
99
  ...configured.map(({ id }) => id),
63
100
  ...discovered.filter((id) => !configured.some((lane) => lane.id === id)),
64
101
  ];
65
- return new Map([...new Set(all)].map((id, index) => {
102
+ const unique = [...new Set(all)];
103
+ return new Map(unique.map((id, index) => {
66
104
  const lane = configured.find((candidate) => candidate.id === id);
67
- const defaultX = index * spacing;
105
+ const defaultX = Math.round((index - (unique.length - 1) / 2) * spacing);
68
106
  const bounded = Math.max(lane?.minimumX ?? -Infinity, Math.min(lane?.maximumX ?? Infinity, defaultX));
69
107
  return [id, bounded];
70
108
  }));
@@ -98,6 +136,71 @@ function finiteLaneBoundDetails(bounds) {
98
136
  function prerequisiteIds(focus) {
99
137
  return focus.prerequisites.groups.flatMap(({ focusIds }) => focusIds);
100
138
  }
139
+ function automaticSiblingLayout(plan, focuses, laneIds, spacing) {
140
+ const placementCohorts = new Map();
141
+ const metricCohorts = new Map();
142
+ const configuredLaneOrder = new Map([...plan.laneGroups]
143
+ .sort((left, right) => left.order - right.order || compareCodeUnits(left.id, right.id))
144
+ .map((lane, index) => [lane.id, index]));
145
+ for (const focus of focuses.values()) {
146
+ if (focus.position.mode !== 'auto')
147
+ continue;
148
+ const parents = [...prerequisiteIds(focus)].sort((left, right) => compareCodeUnits(left, right));
149
+ const placementKey = JSON.stringify([
150
+ parents.length === 0 ? (laneIds.get(focus.id) ?? 'default') : null,
151
+ parents,
152
+ focus.position.preferredX ?? null,
153
+ focus.position.preferredY ?? null,
154
+ ]);
155
+ const metricKey = JSON.stringify([
156
+ parents.length === 0 ? (laneIds.get(focus.id) ?? 'default') : null,
157
+ parents,
158
+ focus.position.preferredY ?? null,
159
+ ]);
160
+ const placementCohort = placementCohorts.get(placementKey) ?? [];
161
+ placementCohort.push(focus.id);
162
+ placementCohorts.set(placementKey, placementCohort);
163
+ const metricCohort = metricCohorts.get(metricKey) ?? [];
164
+ metricCohort.push(focus.id);
165
+ metricCohorts.set(metricKey, metricCohort);
166
+ }
167
+ const result = new Map();
168
+ for (const cohort of placementCohorts.values()) {
169
+ cohort.sort((left, right) => {
170
+ const leftLane = laneIds.get(left) ?? 'default';
171
+ const rightLane = laneIds.get(right) ?? 'default';
172
+ return ((configuredLaneOrder.get(leftLane) ?? Number.MAX_SAFE_INTEGER) -
173
+ (configuredLaneOrder.get(rightLane) ?? Number.MAX_SAFE_INTEGER) ||
174
+ compareCodeUnits(leftLane, rightLane) ||
175
+ compareCodeUnits(left, right));
176
+ });
177
+ for (const [index, focusId] of cohort.entries()) {
178
+ // Integer HOI4 coordinates cannot center an even cohort on an integer
179
+ // column. Floor keeps exact mirror spacing around the adjacent half-grid.
180
+ result.set(focusId, Math.floor(((2 * index - (cohort.length - 1)) * spacing) / 2));
181
+ }
182
+ }
183
+ for (const cohort of metricCohorts.values()) {
184
+ cohort.sort((left, right) => compareCodeUnits(left, right));
185
+ }
186
+ return {
187
+ offsets: result,
188
+ cohorts: [...metricCohorts.values()].filter((cohort) => cohort.length > 1),
189
+ };
190
+ }
191
+ function structuralAnchorX(context, focus, parents, laneId) {
192
+ if (parents.length > 1 && focus.convergence) {
193
+ return Math.floor(parents.reduce((total, parent) => total + parent.x, 0) / parents.length);
194
+ }
195
+ if (parents.length > 0) {
196
+ const ordered = parents.map(({ x }) => x).sort((left, right) => left - right);
197
+ const middle = Math.floor(ordered.length / 2);
198
+ if (ordered.length % 2 === 1)
199
+ return ordered[middle] ?? 0;
200
+ return Math.floor(((ordered[middle - 1] ?? 0) + (ordered[middle] ?? 0)) / 2);
201
+ }
202
+ return context.laneBases.get(laneId) ?? 0;
203
+ }
101
204
  function mutualExclusionMap(focuses) {
102
205
  const result = new Map();
103
206
  for (const focus of focuses.values()) {
@@ -148,25 +251,44 @@ function connectorsShareEndpoint(left, right) {
148
251
  left.childId === right.parentId ||
149
252
  left.childId === right.childId);
150
253
  }
254
+ function connectorVerticalInteriorsOverlap(left, right) {
255
+ const leftMinimum = Math.min(left.parent.y, left.child.y);
256
+ const leftMaximum = Math.max(left.parent.y, left.child.y);
257
+ const rightMinimum = Math.min(right.parent.y, right.child.y);
258
+ const rightMaximum = Math.max(right.parent.y, right.child.y);
259
+ return Math.max(leftMinimum, rightMinimum) < Math.min(leftMaximum, rightMaximum);
260
+ }
151
261
  function* connectorCrossings(edges, context) {
152
262
  const crossings = [];
153
- for (let leftIndex = 0; leftIndex < edges.length; leftIndex += 1) {
263
+ const flattened = edges.map((edge) => ({
264
+ edge,
265
+ points: flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child)),
266
+ }));
267
+ let crossingCount = 0;
268
+ for (let leftIndex = 0; leftIndex < flattened.length; leftIndex += 1) {
154
269
  yield* cancellationCheckpoint(context.signal, leftIndex, 16);
155
- const left = edges[leftIndex];
270
+ const left = flattened[leftIndex];
156
271
  if (left === undefined)
157
272
  continue;
158
- for (let rightIndex = leftIndex + 1; rightIndex < edges.length; rightIndex += 1) {
273
+ for (let rightIndex = leftIndex + 1; rightIndex < flattened.length; rightIndex += 1) {
159
274
  yield* cancellationCheckpoint(context.signal, rightIndex - leftIndex, 64);
160
- context.work.spend('connector crossing comparison');
161
275
  const right = edges[rightIndex];
162
- if (right === undefined || connectorsShareEndpoint(left, right))
276
+ const rightFlattened = flattened[rightIndex];
277
+ if (right === undefined || rightFlattened === undefined)
278
+ continue;
279
+ context.work.spend('connector pair examination');
280
+ if (connectorsShareEndpoint(left.edge, right))
163
281
  continue;
164
- if (focusConnectorSegmentsProperlyCross(left.parent, left.child, right.parent, right.child))
282
+ if (!connectorVerticalInteriorsOverlap(left.edge, right))
283
+ continue;
284
+ if (focusConnectorPolylinesIntersect(left.points, rightFlattened.points)) {
285
+ crossingCount += 1;
165
286
  if (crossings.length < FOCUS_DIAGNOSTIC_MAX - 1)
166
- crossings.push([left, right]);
287
+ crossings.push([left.edge, right]);
288
+ }
167
289
  }
168
290
  }
169
- return crossings;
291
+ return { count: crossingCount, samples: crossings };
170
292
  }
171
293
  function* crossingCountForCandidate(context, focusId, x, y, stopAt = Infinity) {
172
294
  yield* cancellationCheckpoint(context.signal);
@@ -193,18 +315,32 @@ function* crossingCountForCandidate(context, focusId, x, y, stopAt = Infinity) {
193
315
  });
194
316
  }
195
317
  }
196
- const existingEdges = context.placedConnectors.values();
318
+ // Snapshot once so every incident candidate edge is compared. Reusing a
319
+ // Map iterator here silently exhausted it after the first incident edge.
320
+ const existingEdges = [...context.placedConnectors.values()];
321
+ const flattenedCandidates = candidateEdges.map((edge) => ({
322
+ edge,
323
+ points: flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child)),
324
+ }));
325
+ const flattenedExisting = existingEdges.map((edge) => ({
326
+ edge,
327
+ points: flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child)),
328
+ }));
197
329
  let crossings = 0;
198
- for (const [candidateIndex, candidateEdge] of candidateEdges.entries()) {
330
+ for (const [candidateIndex, candidate] of flattenedCandidates.entries()) {
199
331
  yield* cancellationCheckpoint(context.signal, candidateIndex, 16);
200
332
  let existingIndex = 0;
201
- for (const existingEdge of existingEdges) {
333
+ for (const existing of flattenedExisting) {
202
334
  yield* cancellationCheckpoint(context.signal, existingIndex, 64);
203
335
  existingIndex += 1;
204
- context.work.spend('candidate connector crossing comparison');
336
+ const candidateEdge = candidate.edge;
337
+ const existingEdge = existing.edge;
338
+ context.work.spend('candidate connector pair examination');
205
339
  if (connectorsShareEndpoint(candidateEdge, existingEdge))
206
340
  continue;
207
- if (focusConnectorSegmentsProperlyCross(candidateEdge.parent, candidateEdge.child, existingEdge.parent, existingEdge.child)) {
341
+ if (!connectorVerticalInteriorsOverlap(candidateEdge, existingEdge))
342
+ continue;
343
+ if (focusConnectorPolylinesIntersect(candidate.points, existing.points)) {
208
344
  crossings += 1;
209
345
  if (crossings >= stopAt)
210
346
  return crossings;
@@ -213,6 +349,16 @@ function* crossingCountForCandidate(context, focusId, x, y, stopAt = Infinity) {
213
349
  }
214
350
  return crossings;
215
351
  }
352
+ function horizontalSpanForCandidate(context, focusId, x) {
353
+ let span = 0;
354
+ for (const definition of context.connectorDefinitionsByFocus.get(focusId) ?? []) {
355
+ const otherId = definition.parentId === focusId ? definition.childId : definition.parentId;
356
+ const other = context.placed.get(otherId);
357
+ if (other !== undefined)
358
+ span += Math.abs(x - other.x);
359
+ }
360
+ return span;
361
+ }
216
362
  function candidateConflicts(context, focusId, x, y) {
217
363
  context.work.spend('candidate overlap lookup');
218
364
  // Focus coordinates are integers and the rendered node is smaller than one
@@ -221,6 +367,19 @@ function candidateConflicts(context, focusId, x, y) {
221
367
  // placed node for every automatic candidate.
222
368
  const occupiedBy = context.occupied.get(coordinateKey(x, y));
223
369
  const visibleOverlaps = occupiedBy === undefined || occupiedBy === focusId ? [] : [occupiedBy];
370
+ const focusSpacing = [];
371
+ const occupiedRow = context.occupiedRows.get(y);
372
+ if (occupiedRow !== undefined) {
373
+ for (let offset = 1; offset < context.nodeSpacing; offset += 1) {
374
+ context.work.spend('candidate same-row spacing lookup', 2);
375
+ for (const candidateX of [x - offset, x + offset]) {
376
+ const neighbor = occupiedRow.get(candidateX);
377
+ if (neighbor !== undefined && neighbor !== focusId)
378
+ focusSpacing.push(neighbor);
379
+ }
380
+ }
381
+ }
382
+ focusSpacing.sort((left, right) => compareCodeUnits(left, right));
224
383
  const mutualExclusions = [];
225
384
  for (const otherId of context.mutualExclusions.get(focusId) ?? []) {
226
385
  context.work.spend('candidate mutual-exclusion comparison');
@@ -229,10 +388,10 @@ function candidateConflicts(context, focusId, x, y) {
229
388
  mutualExclusions.push(otherId);
230
389
  }
231
390
  mutualExclusions.sort((left, right) => compareCodeUnits(left, right));
232
- return { visibleOverlaps, mutualExclusions };
391
+ return { visibleOverlaps, focusSpacing, mutualExclusions };
233
392
  }
234
- function conflictsCandidate({ visibleOverlaps, mutualExclusions }) {
235
- return visibleOverlaps.length > 0 || mutualExclusions.length > 0;
393
+ function conflictsCandidate({ visibleOverlaps, focusSpacing, mutualExclusions, }) {
394
+ return visibleOverlaps.length > 0 || focusSpacing.length > 0 || mutualExclusions.length > 0;
236
395
  }
237
396
  function* placedPrerequisites(context, focus, stack) {
238
397
  const result = [];
@@ -259,6 +418,9 @@ function recordOccupied(context, node) {
259
418
  }
260
419
  else {
261
420
  context.occupied.set(key, node.id);
421
+ const occupiedRow = context.occupiedRows.get(node.y) ?? new Map();
422
+ occupiedRow.set(node.x, node.id);
423
+ context.occupiedRows.set(node.y, occupiedRow);
262
424
  }
263
425
  }
264
426
  function recordPlaced(context, node) {
@@ -278,6 +440,28 @@ function recordPlaced(context, node) {
278
440
  });
279
441
  }
280
442
  }
443
+ function movePlacedFocusX(context, node, x) {
444
+ context.occupied.delete(coordinateKey(node.x, node.y));
445
+ const oldRow = context.occupiedRows.get(node.y);
446
+ oldRow?.delete(node.x);
447
+ if (oldRow?.size === 0)
448
+ context.occupiedRows.delete(node.y);
449
+ node.x = x;
450
+ context.occupied.set(coordinateKey(node.x, node.y), node.id);
451
+ const newRow = context.occupiedRows.get(node.y) ?? new Map();
452
+ newRow.set(node.x, node.id);
453
+ context.occupiedRows.set(node.y, newRow);
454
+ }
455
+ function rebuildOccupancy(context) {
456
+ context.occupied.clear();
457
+ context.occupiedRows.clear();
458
+ for (const node of context.placed.values()) {
459
+ context.occupied.set(coordinateKey(node.x, node.y), node.id);
460
+ const row = context.occupiedRows.get(node.y) ?? new Map();
461
+ row.set(node.x, node.id);
462
+ context.occupiedRows.set(node.y, row);
463
+ }
464
+ }
281
465
  function* availableX(context, focusId, preferred, y) {
282
466
  const conflictsAtPreferred = candidateConflicts(context, focusId, preferred, y);
283
467
  const crossingsAtPreferred = yield* crossingCountForCandidate(context, focusId, preferred, y);
@@ -292,7 +476,12 @@ function* availableX(context, focusId, preferred, y) {
292
476
  }
293
477
  let best = conflictsCandidate(conflictsAtPreferred)
294
478
  ? undefined
295
- : { x: preferred, crossings: crossingsAtPreferred, distance: 0, directionOrder: 0 };
479
+ : {
480
+ x: preferred,
481
+ crossings: crossingsAtPreferred,
482
+ distance: 0,
483
+ horizontalSpan: horizontalSpanForCandidate(context, focusId, preferred),
484
+ };
296
485
  // Crossing optimization is deliberately local to the declared lane or
297
486
  // preferred coordinate. Scaling the radius with total tree size made one
298
487
  // unavoidable crossing consume the entire global work budget on large
@@ -302,11 +491,8 @@ function* availableX(context, focusId, preferred, y) {
302
491
  const optimizationSteps = 32;
303
492
  for (let distance = 1; distance <= optimizationSteps; distance += 1) {
304
493
  yield* cancellationCheckpoint(context.signal, distance, 8);
305
- const offsets = [distance, -distance];
306
- for (let directionOrder = 0; directionOrder < offsets.length; directionOrder += 1) {
307
- const offset = offsets[directionOrder];
308
- if (offset === undefined)
309
- continue;
494
+ const offsets = [-distance, distance];
495
+ for (const offset of offsets) {
310
496
  context.work.spend('automatic placement candidate');
311
497
  const x = preferred + offset * context.nodeSpacing;
312
498
  if (!coordinateWithinLane(context, focusId, x))
@@ -315,24 +501,34 @@ function* availableX(context, focusId, preferred, y) {
315
501
  if (conflictsCandidate(conflicts))
316
502
  continue;
317
503
  const crossings = yield* crossingCountForCandidate(context, focusId, x, y, best?.crossings);
504
+ const horizontalSpan = horizontalSpanForCandidate(context, focusId, x);
318
505
  if (best === undefined ||
319
506
  crossings < best.crossings ||
320
507
  (crossings === best.crossings && distance < best.distance) ||
321
508
  (crossings === best.crossings &&
322
509
  distance === best.distance &&
323
- directionOrder < best.directionOrder)) {
324
- best = { x, crossings, distance, directionOrder };
325
- }
326
- if (crossings === 0) {
327
- return {
328
- x,
329
- moved: x !== preferred,
330
- conflictsAtPreferred,
331
- crossingsAtPreferred,
332
- crossings,
333
- };
510
+ horizontalSpan < best.horizontalSpan) ||
511
+ (crossings === best.crossings &&
512
+ distance === best.distance &&
513
+ horizontalSpan === best.horizontalSpan &&
514
+ Math.abs(x) < Math.abs(best.x)) ||
515
+ (crossings === best.crossings &&
516
+ distance === best.distance &&
517
+ horizontalSpan === best.horizontalSpan &&
518
+ Math.abs(x) === Math.abs(best.x) &&
519
+ x < best.x)) {
520
+ best = { x, crossings, distance, horizontalSpan };
334
521
  }
335
522
  }
523
+ if (best?.crossings === 0 && best.distance === distance) {
524
+ return {
525
+ x: best.x,
526
+ moved: best.x !== preferred,
527
+ conflictsAtPreferred,
528
+ crossingsAtPreferred,
529
+ crossings: best.crossings,
530
+ };
531
+ }
336
532
  }
337
533
  if (best !== undefined) {
338
534
  return {
@@ -345,7 +541,7 @@ function* availableX(context, focusId, preferred, y) {
345
541
  }
346
542
  for (let distance = optimizationSteps + 1; distance < 10_000; distance += 1) {
347
543
  yield* cancellationCheckpoint(context.signal, distance, 16);
348
- for (const offset of [distance, -distance]) {
544
+ for (const offset of [-distance, distance]) {
349
545
  context.work.spend('automatic placement fallback candidate');
350
546
  const x = preferred + offset * context.nodeSpacing;
351
547
  if (!coordinateWithinLane(context, focusId, x))
@@ -365,6 +561,336 @@ function* availableX(context, focusId, preferred, y) {
365
561
  const bounds = boundsForFocus(context, focusId);
366
562
  throw new ServiceError('FOCUS_LAYOUT_LANE_CAPACITY_BLOCKED', 'No collision-free coordinate is available inside the focus lane bounds', { focusId, laneId, ...finiteLaneBoundDetails(bounds) });
367
563
  }
564
+ function betterGlobalLayoutQualityScore(candidate, current) {
565
+ return (candidate.crossingCount < current.crossingCount ||
566
+ (candidate.crossingCount === current.crossingCount &&
567
+ candidate.maximumHorizontalSpan < current.maximumHorizontalSpan) ||
568
+ (candidate.crossingCount === current.crossingCount &&
569
+ candidate.maximumHorizontalSpan === current.maximumHorizontalSpan &&
570
+ candidate.columnCount < current.columnCount) ||
571
+ (candidate.crossingCount === current.crossingCount &&
572
+ candidate.maximumHorizontalSpan === current.maximumHorizontalSpan &&
573
+ candidate.columnCount === current.columnCount &&
574
+ candidate.totalHorizontalSpan < current.totalHorizontalSpan) ||
575
+ (candidate.crossingCount === current.crossingCount &&
576
+ candidate.maximumHorizontalSpan === current.maximumHorizontalSpan &&
577
+ candidate.columnCount === current.columnCount &&
578
+ candidate.totalHorizontalSpan === current.totalHorizontalSpan &&
579
+ candidate.boundingCenterOffsetTwice < current.boundingCenterOffsetTwice));
580
+ }
581
+ function candidateConnector(context, definition, candidates) {
582
+ const placedParent = context.placed.get(definition.parentId);
583
+ const placedChild = context.placed.get(definition.childId);
584
+ if (placedParent === undefined || placedChild === undefined)
585
+ return undefined;
586
+ const parentX = candidates.get(definition.parentId);
587
+ const childX = candidates.get(definition.childId);
588
+ return {
589
+ parentId: definition.parentId,
590
+ childId: definition.childId,
591
+ parent: parentX === undefined ? placedParent : { ...placedParent, x: parentX },
592
+ child: childX === undefined ? placedChild : { ...placedChild, x: childX },
593
+ };
594
+ }
595
+ function* crossingCountForCandidateSet(context, candidates, stopAt = Infinity) {
596
+ const definitions = new Map();
597
+ for (const focusId of candidates.keys()) {
598
+ for (const definition of context.connectorDefinitionsByFocus.get(focusId) ?? []) {
599
+ definitions.set(definition.key, definition);
600
+ }
601
+ }
602
+ const candidateEdges = [...definitions]
603
+ .sort((left, right) => left[0] - right[0])
604
+ .flatMap(([_key, definition]) => {
605
+ const edge = candidateConnector(context, definition, candidates);
606
+ return edge === undefined ? [] : [edge];
607
+ });
608
+ const candidateIds = new Set(candidates.keys());
609
+ const unaffectedEdges = [...context.placedConnectors.values()].filter((edge) => !candidateIds.has(edge.parentId) && !candidateIds.has(edge.childId));
610
+ const flattenedCandidates = candidateEdges.map((edge) => ({
611
+ edge,
612
+ points: flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child)),
613
+ }));
614
+ const flattenedUnaffected = unaffectedEdges.map((edge) => ({
615
+ edge,
616
+ points: flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child)),
617
+ }));
618
+ let crossings = 0;
619
+ for (const [candidateIndex, candidate] of flattenedCandidates.entries()) {
620
+ yield* cancellationCheckpoint(context.signal, candidateIndex, 8);
621
+ for (const unaffected of flattenedUnaffected) {
622
+ const edge = candidate.edge;
623
+ const unaffectedEdge = unaffected.edge;
624
+ context.work.spend('paired candidate connector pair examination');
625
+ if (connectorsShareEndpoint(edge, unaffectedEdge))
626
+ continue;
627
+ if (!connectorVerticalInteriorsOverlap(edge, unaffectedEdge))
628
+ continue;
629
+ if (!focusConnectorPolylinesIntersect(candidate.points, unaffected.points))
630
+ continue;
631
+ crossings += 1;
632
+ if (crossings >= stopAt)
633
+ return crossings;
634
+ }
635
+ for (let otherIndex = candidateIndex + 1; otherIndex < flattenedCandidates.length; otherIndex += 1) {
636
+ const other = flattenedCandidates[otherIndex];
637
+ context.work.spend('paired candidate internal pair examination');
638
+ if (other === undefined ||
639
+ connectorsShareEndpoint(candidate.edge, other.edge) ||
640
+ !connectorVerticalInteriorsOverlap(candidate.edge, other.edge))
641
+ continue;
642
+ if (!focusConnectorPolylinesIntersect(candidate.points, other.points))
643
+ continue;
644
+ crossings += 1;
645
+ if (crossings >= stopAt)
646
+ return crossings;
647
+ }
648
+ }
649
+ return crossings;
650
+ }
651
+ function candidateSetConflicts(context, candidates) {
652
+ const candidateIds = new Set(candidates.keys());
653
+ for (const [focusId, x] of candidates) {
654
+ if (!coordinateWithinLane(context, focusId, x))
655
+ return true;
656
+ const node = context.placed.get(focusId);
657
+ if (node === undefined)
658
+ return true;
659
+ const row = context.occupiedRows.get(node.y);
660
+ if (row !== undefined) {
661
+ for (let offset = 0; offset < context.nodeSpacing; offset += 1) {
662
+ for (const otherX of offset === 0 ? [x] : [x - offset, x + offset]) {
663
+ const otherId = row.get(otherX);
664
+ if (otherId !== undefined && !candidateIds.has(otherId))
665
+ return true;
666
+ }
667
+ }
668
+ }
669
+ for (const excludedId of context.mutualExclusions.get(focusId) ?? []) {
670
+ const excludedX = candidates.get(excludedId) ?? context.placed.get(excludedId)?.x;
671
+ if (excludedX !== undefined &&
672
+ Math.abs(x - excludedX) < context.minimumMutualExclusionSpacing)
673
+ return true;
674
+ }
675
+ }
676
+ const entries = [...candidates];
677
+ for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
678
+ const [leftId, leftX] = entries[leftIndex] ?? [];
679
+ const left = leftId === undefined ? undefined : context.placed.get(leftId);
680
+ if (leftId === undefined || leftX === undefined || left === undefined)
681
+ continue;
682
+ for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
683
+ const [rightId, rightX] = entries[rightIndex] ?? [];
684
+ const right = rightId === undefined ? undefined : context.placed.get(rightId);
685
+ if (rightId === undefined || rightX === undefined || right === undefined)
686
+ continue;
687
+ if (left.y === right.y && Math.abs(leftX - rightX) < context.nodeSpacing)
688
+ return true;
689
+ }
690
+ }
691
+ return false;
692
+ }
693
+ function* repairConnectorCrossings(context) {
694
+ const maximumAcceptedMoves = 10;
695
+ const maximumDistance = 24;
696
+ const maximumPairDistance = 8;
697
+ const maximumPairCandidates = 1_000;
698
+ const maximumRepairWork = 500_000;
699
+ const repairStartedAt = context.work.consumed;
700
+ let pairCandidates = 0;
701
+ for (let acceptedMoves = 0; acceptedMoves < maximumAcceptedMoves; acceptedMoves += 1) {
702
+ if (context.work.consumed - repairStartedAt >= maximumRepairWork)
703
+ return;
704
+ yield* cancellationCheckpoint(context.signal, acceptedMoves);
705
+ const edges = yield* connectorEdges(context);
706
+ const crossingSummary = yield* connectorCrossings(edges, context);
707
+ if (crossingSummary.count === 0)
708
+ return;
709
+ const horizontalSpans = edges.map((edge) => ({ edge, span: connectorSpans(edge).horizontal }));
710
+ const totalHorizontalSpan = horizontalSpans.reduce((total, entry) => total + entry.span, 0);
711
+ const maximumHorizontalSpan = horizontalSpans.reduce((maximum, entry) => Math.max(maximum, entry.span), 0);
712
+ const nodes = [...context.placed.values()];
713
+ const minimumX = Math.min(...nodes.map(({ x }) => x));
714
+ const maximumX = Math.max(...nodes.map(({ x }) => x));
715
+ const currentScore = {
716
+ crossingCount: crossingSummary.count,
717
+ maximumHorizontalSpan,
718
+ columnCount: maximumX - minimumX + 1,
719
+ totalHorizontalSpan,
720
+ boundingCenterOffsetTwice: Math.abs(minimumX + maximumX),
721
+ };
722
+ const endpointIds = [
723
+ ...new Set(crossingSummary.samples.flatMap(([left, right]) => [
724
+ left.parentId,
725
+ left.childId,
726
+ right.parentId,
727
+ right.childId,
728
+ ])),
729
+ ].sort((left, right) => compareCodeUnits(left, right));
730
+ let accepted = false;
731
+ for (const [focusIndex, focusId] of endpointIds.entries()) {
732
+ yield* cancellationCheckpoint(context.signal, focusIndex, 8);
733
+ if (!canMoveForSoftConstraint(context, focusId))
734
+ continue;
735
+ const node = context.placed.get(focusId);
736
+ if (node === undefined)
737
+ continue;
738
+ const incident = horizontalSpans.filter(({ edge }) => edge.parentId === focusId || edge.childId === focusId);
739
+ const nonIncident = horizontalSpans.filter(({ edge }) => edge.parentId !== focusId && edge.childId !== focusId);
740
+ const currentIncidentCrossings = yield* crossingCountForCandidate(context, focusId, node.x, node.y);
741
+ const currentIncidentHorizontalSpan = incident.reduce((total, entry) => total + entry.span, 0);
742
+ const maximumNonIncidentHorizontalSpan = nonIncident.reduce((maximum, entry) => Math.max(maximum, entry.span), 0);
743
+ const otherXCoordinates = nodes.filter(({ id }) => id !== focusId).map(({ x }) => x);
744
+ const minimumOtherX = Math.min(...otherXCoordinates);
745
+ const maximumOtherX = Math.max(...otherXCoordinates);
746
+ context.work.spend('global crossing repair focus snapshot', horizontalSpans.length + nodes.length);
747
+ for (let distance = 1; distance <= maximumDistance && !accepted; distance += 1) {
748
+ if (context.work.consumed - repairStartedAt >= maximumRepairWork)
749
+ return;
750
+ for (const x of [node.x - distance, node.x + distance]) {
751
+ context.work.spend('global crossing repair candidate');
752
+ if (!coordinateWithinLane(context, focusId, x))
753
+ continue;
754
+ if (conflictsCandidate(candidateConflicts(context, focusId, x, node.y)))
755
+ continue;
756
+ const candidateIncidentCrossings = yield* crossingCountForCandidate(context, focusId, x, node.y, currentIncidentCrossings + 1);
757
+ if (candidateIncidentCrossings > currentIncidentCrossings)
758
+ continue;
759
+ const candidateIncidentSpans = incident.map(({ edge }) => {
760
+ const other = edge.parentId === focusId ? edge.child : edge.parent;
761
+ return Math.abs(x - other.x);
762
+ });
763
+ const candidateMinimumX = Math.min(minimumOtherX, x);
764
+ const candidateMaximumX = Math.max(maximumOtherX, x);
765
+ const candidateScore = {
766
+ crossingCount: crossingSummary.count - currentIncidentCrossings + candidateIncidentCrossings,
767
+ maximumHorizontalSpan: Math.max(maximumNonIncidentHorizontalSpan, ...candidateIncidentSpans),
768
+ columnCount: candidateMaximumX - candidateMinimumX + 1,
769
+ totalHorizontalSpan: totalHorizontalSpan -
770
+ currentIncidentHorizontalSpan +
771
+ candidateIncidentSpans.reduce((total, span) => total + span, 0),
772
+ boundingCenterOffsetTwice: Math.abs(candidateMinimumX + candidateMaximumX),
773
+ };
774
+ if (!betterGlobalLayoutQualityScore(candidateScore, currentScore))
775
+ continue;
776
+ context.decisions.push({
777
+ focusId,
778
+ kind: 'moved_to_reduce_crossings',
779
+ message: `Global crossing repair moved (${node.x}, ${node.y}) to (${x}, ${node.y}); crossings ${currentScore.crossingCount} -> ${candidateScore.crossingCount}`,
780
+ });
781
+ movePlacedFocusX(context, node, x);
782
+ accepted = true;
783
+ break;
784
+ }
785
+ }
786
+ if (accepted)
787
+ break;
788
+ }
789
+ for (let leftIndex = 0; leftIndex < endpointIds.length && !accepted; leftIndex += 1) {
790
+ const leftId = endpointIds[leftIndex];
791
+ if (leftId === undefined || !canMoveForSoftConstraint(context, leftId))
792
+ continue;
793
+ const leftNode = context.placed.get(leftId);
794
+ if (leftNode === undefined)
795
+ continue;
796
+ for (let rightIndex = leftIndex + 1; rightIndex < endpointIds.length && !accepted; rightIndex += 1) {
797
+ const rightId = endpointIds[rightIndex];
798
+ if (rightId === undefined || !canMoveForSoftConstraint(context, rightId))
799
+ continue;
800
+ const rightNode = context.placed.get(rightId);
801
+ if (rightNode === undefined)
802
+ continue;
803
+ const focusIds = new Set([leftId, rightId]);
804
+ const incidentEdges = edges.filter((edge) => focusIds.has(edge.parentId) || focusIds.has(edge.childId));
805
+ const nonIncidentEdges = edges.filter((edge) => !focusIds.has(edge.parentId) && !focusIds.has(edge.childId));
806
+ const currentCandidates = new Map([
807
+ [leftId, leftNode.x],
808
+ [rightId, rightNode.x],
809
+ ]);
810
+ const currentAffectedCrossings = yield* crossingCountForCandidateSet(context, currentCandidates);
811
+ const currentIncidentHorizontalSpan = incidentEdges.reduce((total, edge) => total + connectorSpans(edge).horizontal, 0);
812
+ const maximumNonIncidentHorizontalSpan = nonIncidentEdges.reduce((maximum, edge) => Math.max(maximum, connectorSpans(edge).horizontal), 0);
813
+ const otherXCoordinates = nodes.filter(({ id }) => !focusIds.has(id)).map(({ x }) => x);
814
+ const minimumOtherX = Math.min(...otherXCoordinates);
815
+ const maximumOtherX = Math.max(...otherXCoordinates);
816
+ for (let leftDistance = 1; leftDistance <= maximumPairDistance && !accepted; leftDistance += 1) {
817
+ for (const leftX of [leftNode.x - leftDistance, leftNode.x + leftDistance]) {
818
+ for (let rightDistance = 1; rightDistance <= maximumPairDistance && !accepted; rightDistance += 1) {
819
+ for (const rightX of [rightNode.x - rightDistance, rightNode.x + rightDistance]) {
820
+ if (context.work.consumed - repairStartedAt >= maximumRepairWork)
821
+ return;
822
+ pairCandidates += 1;
823
+ yield* cancellationCheckpoint(context.signal, pairCandidates, 128);
824
+ context.work.spend('paired global crossing repair candidate');
825
+ if (pairCandidates > maximumPairCandidates)
826
+ return;
827
+ const candidates = new Map([
828
+ [leftId, leftX],
829
+ [rightId, rightX],
830
+ ]);
831
+ if (candidateSetConflicts(context, candidates))
832
+ continue;
833
+ const candidateAffectedCrossings = yield* crossingCountForCandidateSet(context, candidates, currentAffectedCrossings + 1);
834
+ if (candidateAffectedCrossings > currentAffectedCrossings)
835
+ continue;
836
+ const candidateIncidentSpans = incidentEdges.map((edge) => Math.abs((candidates.get(edge.parentId) ?? edge.parent.x) -
837
+ (candidates.get(edge.childId) ?? edge.child.x)));
838
+ const candidateMinimumX = Math.min(minimumOtherX, leftX, rightX);
839
+ const candidateMaximumX = Math.max(maximumOtherX, leftX, rightX);
840
+ const candidateScore = {
841
+ crossingCount: crossingSummary.count - currentAffectedCrossings + candidateAffectedCrossings,
842
+ maximumHorizontalSpan: Math.max(maximumNonIncidentHorizontalSpan, ...candidateIncidentSpans),
843
+ columnCount: candidateMaximumX - candidateMinimumX + 1,
844
+ totalHorizontalSpan: totalHorizontalSpan -
845
+ currentIncidentHorizontalSpan +
846
+ candidateIncidentSpans.reduce((total, span) => total + span, 0),
847
+ boundingCenterOffsetTwice: Math.abs(candidateMinimumX + candidateMaximumX),
848
+ };
849
+ if (!betterGlobalLayoutQualityScore(candidateScore, currentScore))
850
+ continue;
851
+ context.decisions.push({
852
+ focusId: leftId,
853
+ kind: 'moved_to_reduce_crossings',
854
+ message: `Paired crossing repair moved (${leftNode.x}, ${leftNode.y}) to (${leftX}, ${leftNode.y}); crossings ${currentScore.crossingCount} -> ${candidateScore.crossingCount}`,
855
+ }, {
856
+ focusId: rightId,
857
+ kind: 'moved_to_reduce_crossings',
858
+ message: `Paired crossing repair moved (${rightNode.x}, ${rightNode.y}) to (${rightX}, ${rightNode.y}); crossings ${currentScore.crossingCount} -> ${candidateScore.crossingCount}`,
859
+ });
860
+ leftNode.x = leftX;
861
+ rightNode.x = rightX;
862
+ rebuildOccupancy(context);
863
+ accepted = true;
864
+ break;
865
+ }
866
+ }
867
+ }
868
+ }
869
+ }
870
+ }
871
+ if (!accepted)
872
+ return;
873
+ }
874
+ }
875
+ function centerFullyAutomaticLayout(context) {
876
+ if (context.previous.size > 0 ||
877
+ [...context.focuses.values()].some(({ position }) => position.mode !== 'auto') ||
878
+ [...context.laneBounds.values()].some(({ minimumX, maximumX }) => Number.isFinite(minimumX) || Number.isFinite(maximumX)))
879
+ return;
880
+ const nodes = [...context.placed.values()];
881
+ if (nodes.length === 0)
882
+ return;
883
+ const minimumX = Math.min(...nodes.map(({ x }) => x));
884
+ const maximumX = Math.max(...nodes.map(({ x }) => x));
885
+ const shift = Math.round(-(minimumX + maximumX) / 2);
886
+ if (shift === 0)
887
+ return;
888
+ for (const node of nodes)
889
+ node.x += shift;
890
+ for (const [laneId, base] of context.laneBases)
891
+ context.laneBases.set(laneId, base + shift);
892
+ rebuildOccupancy(context);
893
+ }
368
894
  function* placeFocus(context, focusId, inheritedStack = new Set()) {
369
895
  yield* cancellationCheckpoint(context.signal);
370
896
  context.work.spend('focus placement');
@@ -468,52 +994,65 @@ function* placeFocus(context, focusId, inheritedStack = new Set()) {
468
994
  context.decisions.push({
469
995
  focusId,
470
996
  kind: 'preserved',
471
- message: `Preserved prior automatic coordinate (${node.x}, ${node.y}); spacing and crossing optimization did not move this stable node`,
997
+ message: `Preserved prior automatic coordinate (${node.x}, ${node.y})`,
472
998
  });
999
+ recordPlaced(context, node);
1000
+ return node;
473
1001
  }
474
- else {
475
- const convergenceX = focus.convergence && parents.length > 1
476
- ? Math.floor(parents.reduce((total, parent) => total + parent.x, 0) / parents.length)
477
- : undefined;
478
- const requestedPreferredX = focus.position.preferredX ?? convergenceX ?? context.laneBases.get(laneId) ?? 0;
479
- const bounds = boundsForFocus(context, focusId);
480
- const preferredX = Math.max(bounds.minimumX, Math.min(bounds.maximumX, requestedPreferredX));
481
- const preferredY = Math.max(requiredY, focus.position.preferredY ?? requiredY);
482
- const coordinate = yield* availableX(context, focusId, preferredX, preferredY);
483
- node = {
484
- id: focusId,
485
- x: coordinate.x,
486
- y: preferredY,
487
- laneId,
488
- preserved: false,
489
- sourceMode: 'auto',
490
- };
491
- const explanations = [];
492
- if (preferredX !== requestedPreferredX) {
493
- explanations.push(`lane ${laneId} bounds clamped x ${requestedPreferredX} -> ${preferredX}`);
494
- }
495
- if (coordinate.conflictsAtPreferred.visibleOverlaps.length > 0) {
496
- explanations.push(`visible overlap with ${coordinate.conflictsAtPreferred.visibleOverlaps.join(', ')}`);
497
- }
498
- if (coordinate.conflictsAtPreferred.mutualExclusions.length > 0) {
499
- explanations.push(`mutual-exclusion spacing from ${coordinate.conflictsAtPreferred.mutualExclusions.join(', ')}`);
500
- }
501
- if (coordinate.crossings < coordinate.crossingsAtPreferred) {
502
- explanations.push(`connector crossings ${coordinate.crossingsAtPreferred} -> ${coordinate.crossings}`);
503
- }
504
- const movedKind = coordinate.conflictsAtPreferred.visibleOverlaps.length > 0
505
- ? 'moved_for_collision'
1002
+ const structuralX = structuralAnchorX(context, focus, parents, laneId) +
1003
+ (focus.position.preferredX === undefined ? (context.siblingOffsets.get(focusId) ?? 0) : 0);
1004
+ const requestedPreferredX = focus.position.preferredX ?? structuralX;
1005
+ const requestedPreferredY = focus.position.preferredY ?? requiredY;
1006
+ const bounds = boundsForFocus(context, focusId);
1007
+ const preferredX = Math.max(bounds.minimumX, Math.min(bounds.maximumX, requestedPreferredX));
1008
+ const preferredY = Math.max(requiredY, requestedPreferredY);
1009
+ const coordinate = yield* availableX(context, focusId, preferredX, preferredY);
1010
+ const moved = coordinate.x !== requestedPreferredX || preferredY !== requestedPreferredY;
1011
+ node = {
1012
+ id: focusId,
1013
+ x: coordinate.x,
1014
+ y: preferredY,
1015
+ laneId,
1016
+ preserved: false,
1017
+ sourceMode: 'auto',
1018
+ };
1019
+ const explanations = [];
1020
+ if (preferredX !== requestedPreferredX) {
1021
+ explanations.push(`lane ${laneId} bounds clamped x ${requestedPreferredX} -> ${preferredX}`);
1022
+ }
1023
+ if (preferredY !== requestedPreferredY) {
1024
+ explanations.push(`parent ordering raised y ${requestedPreferredY} -> ${preferredY}`);
1025
+ }
1026
+ if (coordinate.conflictsAtPreferred.visibleOverlaps.length > 0) {
1027
+ explanations.push(`visible overlap with ${coordinate.conflictsAtPreferred.visibleOverlaps.join(', ')}`);
1028
+ }
1029
+ if (coordinate.conflictsAtPreferred.focusSpacing.length > 0) {
1030
+ explanations.push(`same-row spacing from ${coordinate.conflictsAtPreferred.focusSpacing.join(', ')}`);
1031
+ }
1032
+ if (coordinate.conflictsAtPreferred.mutualExclusions.length > 0) {
1033
+ explanations.push(`mutual-exclusion spacing from ${coordinate.conflictsAtPreferred.mutualExclusions.join(', ')}`);
1034
+ }
1035
+ if (coordinate.crossings < coordinate.crossingsAtPreferred) {
1036
+ explanations.push(`connector crossings ${coordinate.crossingsAtPreferred} -> ${coordinate.crossings}`);
1037
+ }
1038
+ const movedKind = coordinate.conflictsAtPreferred.visibleOverlaps.length > 0
1039
+ ? 'moved_for_collision'
1040
+ : coordinate.conflictsAtPreferred.focusSpacing.length > 0
1041
+ ? 'moved_for_spacing'
506
1042
  : coordinate.conflictsAtPreferred.mutualExclusions.length > 0
507
1043
  ? 'moved_for_mutual_exclusion'
508
- : 'moved_to_reduce_crossings';
509
- context.decisions.push({
510
- focusId,
511
- kind: coordinate.moved ? movedKind : 'placed',
512
- message: coordinate.moved
513
- ? `Moved from preferred (${requestedPreferredX}, ${preferredY}) to (${node.x}, ${node.y}) to resolve ${explanations.join(' and ')}`
514
- : `Placed in lane ${laneId} at (${node.x}, ${node.y}); connector crossings ${coordinate.crossings}${preferredX === requestedPreferredX ? '' : `; requested x ${requestedPreferredX} was clamped to lane bounds`}`,
515
- });
516
- }
1044
+ : preferredY !== requestedPreferredY
1045
+ ? 'moved_for_parent_order'
1046
+ : preferredX !== requestedPreferredX
1047
+ ? 'moved_for_lane_bounds'
1048
+ : 'moved_to_reduce_crossings';
1049
+ context.decisions.push({
1050
+ focusId,
1051
+ kind: moved ? movedKind : 'placed',
1052
+ message: moved
1053
+ ? `Moved from preferred (${requestedPreferredX}, ${requestedPreferredY}) to (${node.x}, ${node.y}) to resolve ${explanations.join(' and ')}`
1054
+ : `Placed in lane ${laneId} at (${node.x}, ${node.y}); connector crossings ${coordinate.crossings}`,
1055
+ });
517
1056
  }
518
1057
  const cycleFallback = context.placed.get(focusId);
519
1058
  if (cycleFallback !== undefined)
@@ -549,6 +1088,56 @@ function* visibleOverlapDiagnostics(context) {
549
1088
  }
550
1089
  }
551
1090
  }
1091
+ function* sameRowSpacingDiagnostics(context) {
1092
+ const rows = new Map();
1093
+ for (const node of context.placed.values()) {
1094
+ const row = rows.get(node.y) ?? [];
1095
+ row.push(node);
1096
+ rows.set(node.y, row);
1097
+ }
1098
+ let sameRowPairCount = 0;
1099
+ let tooCloseSameRowPairCount = 0;
1100
+ let minimumSameRowSpacing = Infinity;
1101
+ let rowIndex = 0;
1102
+ for (const [y, row] of [...rows].sort((left, right) => left[0] - right[0])) {
1103
+ yield* cancellationCheckpoint(context.signal, rowIndex, 16);
1104
+ rowIndex += 1;
1105
+ row.sort((left, right) => left.x - right.x || compareCodeUnits(left.id, right.id));
1106
+ for (let index = 1; index < row.length; index += 1) {
1107
+ context.work.spend('same-row spacing diagnostic');
1108
+ const left = row[index - 1];
1109
+ const right = row[index];
1110
+ if (left === undefined || right === undefined)
1111
+ continue;
1112
+ const spacing = right.x - left.x;
1113
+ sameRowPairCount += 1;
1114
+ minimumSameRowSpacing = Math.min(minimumSameRowSpacing, spacing);
1115
+ if (spacing >= context.nodeSpacing)
1116
+ continue;
1117
+ tooCloseSameRowPairCount += 1;
1118
+ const focus = context.focuses.get(right.id);
1119
+ context.diagnostics.push({
1120
+ code: 'FOCUS_LAYOUT_SAME_ROW_SPACING_UNSATISFIED',
1121
+ severity: 'warning',
1122
+ category: 'layout',
1123
+ message: `Focuses ${left.id} and ${right.id} are ${spacing} columns apart on row ${y}; ${context.nodeSpacing} are required`,
1124
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1125
+ details: {
1126
+ focusIds: [left.id, right.id],
1127
+ y,
1128
+ actualSpacing: spacing,
1129
+ requiredSpacing: context.nodeSpacing,
1130
+ movableFocusIds: [left.id, right.id].filter((id) => canMoveForSoftConstraint(context, id)),
1131
+ },
1132
+ });
1133
+ }
1134
+ }
1135
+ return {
1136
+ sameRowPairCount,
1137
+ tooCloseSameRowPairCount,
1138
+ minimumSameRowSpacing: Number.isFinite(minimumSameRowSpacing) ? minimumSameRowSpacing : 0,
1139
+ };
1140
+ }
552
1141
  function canMoveForSoftConstraint(context, focusId) {
553
1142
  const focus = context.focuses.get(focusId);
554
1143
  return focus?.position.mode === 'auto' && !context.previous.has(focusId);
@@ -581,7 +1170,7 @@ function* mutualExclusionSpacingDiagnostics(context) {
581
1170
  const preservedFocusIds = [leftId, rightId].filter((id) => !canMoveForSoftConstraint(context, id));
582
1171
  const focus = context.focuses.get(rightId);
583
1172
  const reason = movableFocusIds.length === 0
584
- ? 'both endpoints are fixed, relative, pinned, or prior-stable'
1173
+ ? 'both endpoints are fixed or relative'
585
1174
  : 'the remaining movable endpoint cannot change the authored relative relationship safely';
586
1175
  context.diagnostics.push({
587
1176
  code: 'FOCUS_LAYOUT_MUTUAL_EXCLUSION_SPACING_UNSATISFIED',
@@ -599,17 +1188,47 @@ function* mutualExclusionSpacingDiagnostics(context) {
599
1188
  });
600
1189
  }
601
1190
  }
602
- function* connectorCrossingDiagnostics(context) {
1191
+ function* connectorNodeIntersectionDiagnostics(context, edges) {
1192
+ const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
1193
+ let comparisonIndex = 0;
1194
+ let intersectionCount = 0;
1195
+ for (const edge of edges) {
1196
+ const flattened = flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child));
1197
+ for (const node of nodes) {
1198
+ yield* cancellationCheckpoint(context.signal, comparisonIndex, 32);
1199
+ comparisonIndex += 1;
1200
+ if (node.id === edge.parentId || node.id === edge.childId)
1201
+ continue;
1202
+ context.work.spend('connector-node intersection comparison');
1203
+ if (!focusConnectorPolylineIntersectsRectangle(flattened, focusNodeRectangle(node)))
1204
+ continue;
1205
+ intersectionCount += 1;
1206
+ const focus = context.focuses.get(node.id);
1207
+ context.diagnostics.push({
1208
+ code: 'FOCUS_LAYOUT_CONNECTOR_THROUGH_NODE',
1209
+ severity: 'warning',
1210
+ category: 'layout',
1211
+ message: `Connector ${edge.parentId} -> ${edge.childId} intersects unrelated focus ${node.id}`,
1212
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1213
+ details: {
1214
+ parentId: edge.parentId,
1215
+ childId: edge.childId,
1216
+ focusId: node.id,
1217
+ },
1218
+ });
1219
+ }
1220
+ }
1221
+ return intersectionCount;
1222
+ }
1223
+ function* connectorCrossingDiagnostics(context, crossings) {
603
1224
  yield* cancellationCheckpoint(context.signal);
604
- const edges = yield* connectorEdges(context);
605
- const crossings = yield* connectorCrossings(edges, context);
606
1225
  for (const [first, second] of crossings) {
607
1226
  const endpointIds = [first.parentId, first.childId, second.parentId, second.childId].filter((id, index, all) => all.indexOf(id) === index);
608
1227
  const movableFocusIds = endpointIds.filter((id) => canMoveForSoftConstraint(context, id));
609
1228
  const preservedFocusIds = endpointIds.filter((id) => !canMoveForSoftConstraint(context, id));
610
1229
  const focus = context.focuses.get(second.childId);
611
1230
  const reason = movableFocusIds.length === 0
612
- ? 'all connector endpoints are fixed, relative, pinned, or prior-stable'
1231
+ ? 'all connector endpoints are fixed or relative'
613
1232
  : 'no collision-free deterministic candidate reduced the crossing without moving preserved endpoints';
614
1233
  context.diagnostics.push({
615
1234
  code: 'FOCUS_LAYOUT_CONNECTOR_CROSSING_UNSATISFIED',
@@ -626,6 +1245,536 @@ function* connectorCrossingDiagnostics(context) {
626
1245
  });
627
1246
  }
628
1247
  }
1248
+ function connectorSpans(edge) {
1249
+ const horizontal = Math.abs(edge.parent.x - edge.child.x);
1250
+ const vertical = Math.abs(edge.parent.y - edge.child.y);
1251
+ return { horizontal, vertical, manhattan: horizontal + vertical };
1252
+ }
1253
+ function longConnectorSpans(spans) {
1254
+ return (spans.horizontal > LONG_CONNECTOR_HORIZONTAL_SPAN ||
1255
+ spans.vertical > LONG_CONNECTOR_VERTICAL_SPAN ||
1256
+ spans.manhattan > LONG_CONNECTOR_MANHATTAN_SPAN);
1257
+ }
1258
+ function longConnector(edge) {
1259
+ return longConnectorSpans(connectorSpans(edge));
1260
+ }
1261
+ function betterConnectorQualityScore(candidate, current) {
1262
+ if (candidate.longConnectorCount > current.longConnectorCount)
1263
+ return false;
1264
+ const fields = [
1265
+ 'crossingCount',
1266
+ 'nodeIntersectionCount',
1267
+ 'maximumManhattanSpan',
1268
+ 'maximumHorizontalSpan',
1269
+ 'longConnectorCount',
1270
+ 'totalManhattanSpan',
1271
+ 'totalHorizontalSpan',
1272
+ 'asymmetricSiblingCohortCount',
1273
+ 'maximumSiblingDeviation',
1274
+ 'totalSiblingDeviation',
1275
+ 'offAnchorSiblingCohortCount',
1276
+ 'maximumSiblingAnchorDeviation',
1277
+ 'totalSiblingAnchorDeviation',
1278
+ 'columnCount',
1279
+ 'boundingCenterOffsetTwice',
1280
+ ];
1281
+ for (const field of fields) {
1282
+ if (candidate[field] === current[field])
1283
+ continue;
1284
+ return candidate[field] < current[field];
1285
+ }
1286
+ return false;
1287
+ }
1288
+ function candidateNode(context, focusId, x) {
1289
+ const current = context.placed.get(focusId);
1290
+ return current === undefined ? undefined : { ...current, x };
1291
+ }
1292
+ function* affectedNodeIntersectionCount(context, edges, focusId, x) {
1293
+ const moved = candidateNode(context, focusId, x);
1294
+ if (moved === undefined)
1295
+ return 0;
1296
+ const nodes = [...context.placed.values()];
1297
+ let count = 0;
1298
+ let comparisonIndex = 0;
1299
+ for (const definition of context.connectorDefinitionsByFocus.get(focusId) ?? []) {
1300
+ const edge = candidateConnector(context, definition, new Map([[focusId, x]]));
1301
+ if (edge === undefined)
1302
+ continue;
1303
+ const flattened = flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child));
1304
+ for (const node of nodes) {
1305
+ yield* cancellationCheckpoint(context.signal, comparisonIndex, 64);
1306
+ comparisonIndex += 1;
1307
+ if (node.id === edge.parentId || node.id === edge.childId)
1308
+ continue;
1309
+ context.work.spend('quality candidate connector-node comparison');
1310
+ if (focusConnectorPolylineIntersectsRectangle(flattened, focusNodeRectangle(node)))
1311
+ count += 1;
1312
+ }
1313
+ }
1314
+ const rectangle = focusNodeRectangle(moved);
1315
+ for (const edge of edges) {
1316
+ if (edge.parentId === focusId || edge.childId === focusId)
1317
+ continue;
1318
+ yield* cancellationCheckpoint(context.signal, comparisonIndex, 64);
1319
+ comparisonIndex += 1;
1320
+ context.work.spend('quality candidate node-connector comparison');
1321
+ const flattened = flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child));
1322
+ if (focusConnectorPolylineIntersectsRectangle(flattened, rectangle))
1323
+ count += 1;
1324
+ }
1325
+ return count;
1326
+ }
1327
+ function* connectorNodeIntersectionSummary(context, edges) {
1328
+ const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
1329
+ const involvedFocusIds = new Set();
1330
+ let count = 0;
1331
+ let comparisonIndex = 0;
1332
+ for (const edge of edges) {
1333
+ const flattened = flattenFocusConnectorCurve(focusConnectorCurve(edge.parent, edge.child));
1334
+ for (const node of nodes) {
1335
+ yield* cancellationCheckpoint(context.signal, comparisonIndex, 64);
1336
+ comparisonIndex += 1;
1337
+ if (node.id === edge.parentId || node.id === edge.childId)
1338
+ continue;
1339
+ context.work.spend('quality connector-node comparison');
1340
+ if (!focusConnectorPolylineIntersectsRectangle(flattened, focusNodeRectangle(node)))
1341
+ continue;
1342
+ count += 1;
1343
+ involvedFocusIds.add(edge.parentId);
1344
+ involvedFocusIds.add(edge.childId);
1345
+ involvedFocusIds.add(node.id);
1346
+ }
1347
+ }
1348
+ return { count, involvedFocusIds };
1349
+ }
1350
+ function siblingQuality(context, override) {
1351
+ let asymmetricSiblingCohortCount = 0;
1352
+ let maximumSiblingDeviation = 0;
1353
+ let totalSiblingDeviation = 0;
1354
+ let offAnchorSiblingCohortCount = 0;
1355
+ let maximumSiblingAnchorDeviation = 0;
1356
+ let totalSiblingAnchorDeviation = 0;
1357
+ const parentFocusIds = new Set();
1358
+ for (const cohort of context.siblingCohorts) {
1359
+ const focus = context.focuses.get(cohort[0] ?? '');
1360
+ if (focus === undefined)
1361
+ continue;
1362
+ const coordinates = cohort
1363
+ .flatMap((focusId) => {
1364
+ const node = context.placed.get(focusId);
1365
+ return node === undefined ? [] : [override?.get(focusId) ?? node.x];
1366
+ })
1367
+ .sort((left, right) => left - right);
1368
+ const parentIds = prerequisiteIds(focus);
1369
+ for (const parentId of parentIds)
1370
+ parentFocusIds.add(parentId);
1371
+ const parentCoordinates = parentIds
1372
+ .flatMap((parentId) => {
1373
+ const node = context.placed.get(parentId);
1374
+ if (node === undefined)
1375
+ return [];
1376
+ return [override?.get(parentId) ?? node.x];
1377
+ })
1378
+ .sort((left, right) => left - right);
1379
+ let anchorX;
1380
+ if (coordinates.length >= 4 &&
1381
+ (coordinates.at(-1) ?? 0) - (coordinates[0] ?? 0) > 2 * LONG_CONNECTOR_HORIZONTAL_SPAN) {
1382
+ anchorX = ((coordinates[0] ?? 0) + (coordinates.at(-1) ?? 0)) / 2;
1383
+ }
1384
+ else if (parentCoordinates.length > 1 && focus.convergence) {
1385
+ anchorX = Math.floor(parentCoordinates.reduce((total, coordinate) => total + coordinate, 0) /
1386
+ parentCoordinates.length);
1387
+ }
1388
+ else if (parentCoordinates.length > 0) {
1389
+ const middle = Math.floor(parentCoordinates.length / 2);
1390
+ anchorX =
1391
+ parentCoordinates.length % 2 === 1
1392
+ ? (parentCoordinates[middle] ?? 0)
1393
+ : Math.floor(((parentCoordinates[middle - 1] ?? 0) + (parentCoordinates[middle] ?? 0)) / 2);
1394
+ }
1395
+ else {
1396
+ anchorX = context.laneBases.get(context.laneIds.get(focus.id) ?? 'default') ?? 0;
1397
+ }
1398
+ const internalCenterTwice = (coordinates[0] ?? 0) + (coordinates.at(-1) ?? 0);
1399
+ let internalDeviation = 0;
1400
+ let anchorDeviation = 0;
1401
+ for (let index = 0; index <= Math.floor((coordinates.length - 1) / 2); index += 1) {
1402
+ const pairSum = (coordinates[index] ?? 0) + (coordinates[coordinates.length - 1 - index] ?? 0);
1403
+ internalDeviation += Math.abs(pairSum - internalCenterTwice);
1404
+ anchorDeviation += Math.abs(pairSum - 2 * anchorX);
1405
+ }
1406
+ totalSiblingDeviation += internalDeviation;
1407
+ maximumSiblingDeviation = Math.max(maximumSiblingDeviation, internalDeviation);
1408
+ if (internalDeviation > 0)
1409
+ asymmetricSiblingCohortCount += 1;
1410
+ totalSiblingAnchorDeviation += anchorDeviation;
1411
+ maximumSiblingAnchorDeviation = Math.max(maximumSiblingAnchorDeviation, anchorDeviation);
1412
+ if (anchorDeviation > 0)
1413
+ offAnchorSiblingCohortCount += 1;
1414
+ }
1415
+ return {
1416
+ asymmetricSiblingCohortCount,
1417
+ maximumSiblingDeviation,
1418
+ totalSiblingDeviation,
1419
+ offAnchorSiblingCohortCount,
1420
+ maximumSiblingAnchorDeviation,
1421
+ totalSiblingAnchorDeviation,
1422
+ parentFocusIds,
1423
+ };
1424
+ }
1425
+ function connectorQualityScore(context, edges, crossingCount, nodeIntersectionCount, symmetry) {
1426
+ const spans = edges.map(connectorSpans);
1427
+ const nodes = [...context.placed.values()];
1428
+ const minimumX = Math.min(...nodes.map(({ x }) => x));
1429
+ const maximumX = Math.max(...nodes.map(({ x }) => x));
1430
+ return {
1431
+ crossingCount,
1432
+ nodeIntersectionCount,
1433
+ asymmetricSiblingCohortCount: symmetry.asymmetricSiblingCohortCount,
1434
+ maximumSiblingDeviation: symmetry.maximumSiblingDeviation,
1435
+ totalSiblingDeviation: symmetry.totalSiblingDeviation,
1436
+ offAnchorSiblingCohortCount: symmetry.offAnchorSiblingCohortCount,
1437
+ maximumSiblingAnchorDeviation: symmetry.maximumSiblingAnchorDeviation,
1438
+ totalSiblingAnchorDeviation: symmetry.totalSiblingAnchorDeviation,
1439
+ longConnectorCount: edges.filter(longConnector).length,
1440
+ maximumManhattanSpan: spans.reduce((maximum, { manhattan }) => Math.max(maximum, manhattan), 0),
1441
+ maximumHorizontalSpan: spans.reduce((maximum, { horizontal }) => Math.max(maximum, horizontal), 0),
1442
+ totalManhattanSpan: spans.reduce((total, { manhattan }) => total + manhattan, 0),
1443
+ totalHorizontalSpan: spans.reduce((total, { horizontal }) => total + horizontal, 0),
1444
+ columnCount: maximumX - minimumX + 1,
1445
+ boundingCenterOffsetTwice: Math.abs(minimumX + maximumX),
1446
+ };
1447
+ }
1448
+ function candidateQualityScore(context, edges, current, focusId, x, currentAffectedCrossings, candidateAffectedCrossings, currentAffectedNodeIntersections, candidateAffectedNodeIntersections) {
1449
+ const incidentEdges = edges.filter((edge) => edge.parentId === focusId || edge.childId === focusId);
1450
+ const nonIncidentEdges = edges.filter((edge) => edge.parentId !== focusId && edge.childId !== focusId);
1451
+ const candidateIncidentEdges = incidentEdges.map((edge) => ({
1452
+ ...edge,
1453
+ parent: edge.parentId === focusId ? { ...edge.parent, x } : edge.parent,
1454
+ child: edge.childId === focusId ? { ...edge.child, x } : edge.child,
1455
+ }));
1456
+ const currentIncidentSpans = incidentEdges.map(connectorSpans);
1457
+ const candidateIncidentSpans = candidateIncidentEdges.map(connectorSpans);
1458
+ const nonIncidentSpans = nonIncidentEdges.map(connectorSpans);
1459
+ const otherXCoordinates = [...context.placed.values()]
1460
+ .filter(({ id }) => id !== focusId)
1461
+ .map(({ x: coordinate }) => coordinate);
1462
+ const minimumX = Math.min(x, ...otherXCoordinates);
1463
+ const maximumX = Math.max(x, ...otherXCoordinates);
1464
+ const symmetry = siblingQuality(context, new Map([[focusId, x]]));
1465
+ const currentLong = currentIncidentSpans.filter(longConnectorSpans).length;
1466
+ const candidateLong = candidateIncidentEdges.filter(longConnector).length;
1467
+ const totalHorizontal = (values) => values.reduce((total, { horizontal }) => total + horizontal, 0);
1468
+ const totalManhattan = (values) => values.reduce((total, { manhattan }) => total + manhattan, 0);
1469
+ return {
1470
+ crossingCount: current.crossingCount - currentAffectedCrossings + candidateAffectedCrossings,
1471
+ nodeIntersectionCount: current.nodeIntersectionCount -
1472
+ currentAffectedNodeIntersections +
1473
+ candidateAffectedNodeIntersections,
1474
+ asymmetricSiblingCohortCount: symmetry.asymmetricSiblingCohortCount,
1475
+ maximumSiblingDeviation: symmetry.maximumSiblingDeviation,
1476
+ totalSiblingDeviation: symmetry.totalSiblingDeviation,
1477
+ offAnchorSiblingCohortCount: symmetry.offAnchorSiblingCohortCount,
1478
+ maximumSiblingAnchorDeviation: symmetry.maximumSiblingAnchorDeviation,
1479
+ totalSiblingAnchorDeviation: symmetry.totalSiblingAnchorDeviation,
1480
+ longConnectorCount: current.longConnectorCount - currentLong + candidateLong,
1481
+ maximumManhattanSpan: Math.max(0, ...nonIncidentSpans.map(({ manhattan }) => manhattan), ...candidateIncidentSpans.map(({ manhattan }) => manhattan)),
1482
+ maximumHorizontalSpan: Math.max(0, ...nonIncidentSpans.map(({ horizontal }) => horizontal), ...candidateIncidentSpans.map(({ horizontal }) => horizontal)),
1483
+ totalManhattanSpan: current.totalManhattanSpan -
1484
+ totalManhattan(currentIncidentSpans) +
1485
+ totalManhattan(candidateIncidentSpans),
1486
+ totalHorizontalSpan: current.totalHorizontalSpan -
1487
+ totalHorizontal(currentIncidentSpans) +
1488
+ totalHorizontal(candidateIncidentSpans),
1489
+ columnCount: maximumX - minimumX + 1,
1490
+ boundingCenterOffsetTwice: Math.abs(minimumX + maximumX),
1491
+ };
1492
+ }
1493
+ const CONNECTOR_REFINEMENT_WORK_MAX = 7_000_000;
1494
+ function* refineConnectorQualityWithinBudget(context) {
1495
+ const maximumAcceptedMoves = 24;
1496
+ const maximumCandidateNodes = 32;
1497
+ const edges = yield* connectorEdges(context);
1498
+ const crossingSummary = yield* connectorCrossings(edges, context);
1499
+ const intersectionSummary = yield* connectorNodeIntersectionSummary(context, edges);
1500
+ const symmetry = siblingQuality(context);
1501
+ let current = connectorQualityScore(context, edges, crossingSummary.count, intersectionSummary.count, symmetry);
1502
+ const siblingMembers = new Set(context.siblingCohorts.flat());
1503
+ const candidateIds = new Set(intersectionSummary.involvedFocusIds);
1504
+ for (const edge of edges.filter(longConnector)) {
1505
+ candidateIds.add(edge.parentId);
1506
+ candidateIds.add(edge.childId);
1507
+ }
1508
+ for (const parentId of symmetry.parentFocusIds)
1509
+ candidateIds.add(parentId);
1510
+ const orderedCandidateIds = [...candidateIds]
1511
+ .filter((focusId) => {
1512
+ const degree = context.connectorDefinitionsByFocus.get(focusId)?.length ?? 0;
1513
+ return (canMoveForSoftConstraint(context, focusId) && !siblingMembers.has(focusId) && degree !== 1);
1514
+ })
1515
+ .sort((left, right) => {
1516
+ const degree = (focusId) => context.connectorDefinitionsByFocus.get(focusId)?.length ?? 0;
1517
+ return degree(right) - degree(left) || compareCodeUnits(left, right);
1518
+ })
1519
+ .slice(0, maximumCandidateNodes);
1520
+ for (let acceptedMoves = 0; acceptedMoves < maximumAcceptedMoves; acceptedMoves += 1) {
1521
+ let best;
1522
+ for (const [focusIndex, focusId] of orderedCandidateIds.entries()) {
1523
+ yield* cancellationCheckpoint(context.signal, focusIndex, 8);
1524
+ const node = context.placed.get(focusId);
1525
+ if (node === undefined)
1526
+ continue;
1527
+ const adjacent = (context.connectorDefinitionsByFocus.get(focusId) ?? []).flatMap((definition) => {
1528
+ const otherId = definition.parentId === focusId ? definition.childId : definition.parentId;
1529
+ const other = context.placed.get(otherId);
1530
+ return other === undefined ? [] : [other.x];
1531
+ });
1532
+ const targets = new Set();
1533
+ for (let distance = 1; distance <= 4; distance += 1) {
1534
+ targets.add(node.x - distance);
1535
+ targets.add(node.x + distance);
1536
+ }
1537
+ if (adjacent.length > 0) {
1538
+ const midpoint = Math.round((Math.min(...adjacent) + Math.max(...adjacent)) / 2);
1539
+ for (let offset = -2; offset <= 2; offset += 1)
1540
+ targets.add(midpoint + offset);
1541
+ }
1542
+ const currentAffectedCrossings = yield* crossingCountForCandidate(context, focusId, node.x, node.y);
1543
+ const currentAffectedNodeIntersections = yield* affectedNodeIntersectionCount(context, edges, focusId, node.x);
1544
+ for (const x of [...targets].sort((left, right) => left - right)) {
1545
+ if (x === node.x || !coordinateWithinLane(context, focusId, x))
1546
+ continue;
1547
+ if (conflictsCandidate(candidateConflicts(context, focusId, x, node.y)))
1548
+ continue;
1549
+ const candidateAffectedCrossings = yield* crossingCountForCandidate(context, focusId, x, node.y, currentAffectedCrossings + 1);
1550
+ if (candidateAffectedCrossings > currentAffectedCrossings)
1551
+ continue;
1552
+ const candidateAffectedNodeIntersections = yield* affectedNodeIntersectionCount(context, edges, focusId, x);
1553
+ const score = candidateQualityScore(context, edges, current, focusId, x, currentAffectedCrossings, candidateAffectedCrossings, currentAffectedNodeIntersections, candidateAffectedNodeIntersections);
1554
+ if (!betterConnectorQualityScore(score, current))
1555
+ continue;
1556
+ if (best !== undefined && !betterConnectorQualityScore(score, best.score))
1557
+ continue;
1558
+ best = {
1559
+ focusId,
1560
+ x,
1561
+ score,
1562
+ message: `Connector-quality refinement moved (${node.x}, ${node.y}) to (${x}, ${node.y})`,
1563
+ };
1564
+ }
1565
+ }
1566
+ if (best === undefined)
1567
+ return;
1568
+ const node = context.placed.get(best.focusId);
1569
+ if (node === undefined)
1570
+ return;
1571
+ context.decisions.push({
1572
+ focusId: best.focusId,
1573
+ kind: 'moved_to_improve_connector_quality',
1574
+ message: best.message,
1575
+ });
1576
+ movePlacedFocusX(context, node, best.x);
1577
+ current = best.score;
1578
+ }
1579
+ }
1580
+ function* refineConnectorQuality(context) {
1581
+ const operationBudget = context.work;
1582
+ context.work = new FocusLayoutSubBudget(operationBudget, CONNECTOR_REFINEMENT_WORK_MAX);
1583
+ try {
1584
+ yield* refineConnectorQualityWithinBudget(context);
1585
+ }
1586
+ catch (error) {
1587
+ if (!(error instanceof FocusLayoutSubBudgetExhausted))
1588
+ throw error;
1589
+ }
1590
+ finally {
1591
+ context.work = operationBudget;
1592
+ }
1593
+ }
1594
+ function* connectorLengthDiagnostics(context, edges) {
1595
+ for (const [edgeIndex, edge] of edges.entries()) {
1596
+ yield* cancellationCheckpoint(context.signal, edgeIndex, 32);
1597
+ context.work.spend('connector length diagnostic');
1598
+ if (!longConnector(edge))
1599
+ continue;
1600
+ const spans = connectorSpans(edge);
1601
+ const focus = context.focuses.get(edge.childId);
1602
+ context.diagnostics.push({
1603
+ code: 'FOCUS_LAYOUT_LONG_CONNECTOR',
1604
+ severity: 'warning',
1605
+ category: 'layout',
1606
+ message: `Connector ${edge.parentId} -> ${edge.childId} spans ${spans.horizontal} columns and ${spans.vertical} rows`,
1607
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1608
+ details: {
1609
+ parentId: edge.parentId,
1610
+ childId: edge.childId,
1611
+ horizontalSpan: spans.horizontal,
1612
+ verticalSpan: spans.vertical,
1613
+ manhattanSpan: spans.manhattan,
1614
+ thresholds: {
1615
+ horizontalSpan: LONG_CONNECTOR_HORIZONTAL_SPAN,
1616
+ verticalSpan: LONG_CONNECTOR_VERTICAL_SPAN,
1617
+ manhattanSpan: LONG_CONNECTOR_MANHATTAN_SPAN,
1618
+ },
1619
+ },
1620
+ });
1621
+ }
1622
+ }
1623
+ function siblingCohortAnchor(context, cohort) {
1624
+ const focus = context.focuses.get(cohort[0] ?? '');
1625
+ const coordinates = cohort
1626
+ .flatMap((focusId) => {
1627
+ const node = context.placed.get(focusId);
1628
+ return node === undefined ? [] : [node.x];
1629
+ })
1630
+ .sort((left, right) => left - right);
1631
+ if (coordinates.length >= 4 &&
1632
+ (coordinates.at(-1) ?? 0) - (coordinates[0] ?? 0) > 2 * LONG_CONNECTOR_HORIZONTAL_SPAN) {
1633
+ return {
1634
+ anchorX: ((coordinates[0] ?? 0) + (coordinates.at(-1) ?? 0)) / 2,
1635
+ anchorKind: 'subtree_envelope',
1636
+ };
1637
+ }
1638
+ if (focus !== undefined) {
1639
+ const parents = prerequisiteIds(focus).flatMap((focusId) => {
1640
+ const parent = context.placed.get(focusId);
1641
+ return parent === undefined ? [] : [parent];
1642
+ });
1643
+ if (parents.length > 0) {
1644
+ return {
1645
+ anchorX: structuralAnchorX(context, focus, parents, context.laneIds.get(focus.id) ?? 'default'),
1646
+ anchorKind: parents.length > 1 && focus.convergence ? 'parent_average' : 'parent_median',
1647
+ };
1648
+ }
1649
+ }
1650
+ const laneId = focus === undefined ? 'default' : (context.laneIds.get(focus.id) ?? 'default');
1651
+ return { anchorX: context.laneBases.get(laneId) ?? 0, anchorKind: 'lane_base' };
1652
+ }
1653
+ function* siblingSymmetryDiagnostics(context) {
1654
+ let asymmetricSiblingCohortCount = 0;
1655
+ let totalSiblingDeviation = 0;
1656
+ let maximumSiblingDeviation = 0;
1657
+ let offAnchorSiblingCohortCount = 0;
1658
+ let totalSiblingAnchorDeviation = 0;
1659
+ let maximumSiblingAnchorDeviation = 0;
1660
+ for (const [cohortIndex, cohort] of context.siblingCohorts.entries()) {
1661
+ yield* cancellationCheckpoint(context.signal, cohortIndex, 16);
1662
+ const nodes = cohort
1663
+ .flatMap((focusId) => {
1664
+ const node = context.placed.get(focusId);
1665
+ return node === undefined ? [] : [node];
1666
+ })
1667
+ .sort((left, right) => left.x - right.x || compareCodeUnits(left.id, right.id));
1668
+ if (nodes.length < 2)
1669
+ continue;
1670
+ const { anchorX, anchorKind } = siblingCohortAnchor(context, cohort);
1671
+ const anchorCenterTwice = 2 * anchorX;
1672
+ const mirrorCenterTwice = (nodes[0]?.x ?? 0) + (nodes.at(-1)?.x ?? 0);
1673
+ let mirrorDeviation = 0;
1674
+ let anchorDeviation = 0;
1675
+ for (let index = 0; index <= Math.floor((nodes.length - 1) / 2); index += 1) {
1676
+ const left = nodes[index];
1677
+ const right = nodes[nodes.length - 1 - index];
1678
+ if (left === undefined || right === undefined)
1679
+ continue;
1680
+ const pairSum = left.x + right.x;
1681
+ mirrorDeviation += Math.abs(pairSum - mirrorCenterTwice);
1682
+ anchorDeviation += Math.abs(pairSum - anchorCenterTwice);
1683
+ }
1684
+ totalSiblingDeviation += mirrorDeviation;
1685
+ maximumSiblingDeviation = Math.max(maximumSiblingDeviation, mirrorDeviation);
1686
+ totalSiblingAnchorDeviation += anchorDeviation;
1687
+ maximumSiblingAnchorDeviation = Math.max(maximumSiblingAnchorDeviation, anchorDeviation);
1688
+ const focus = context.focuses.get(nodes.at(-1)?.id ?? '');
1689
+ if (mirrorDeviation > 0) {
1690
+ asymmetricSiblingCohortCount += 1;
1691
+ context.diagnostics.push({
1692
+ code: 'FOCUS_LAYOUT_SIBLING_ASYMMETRY',
1693
+ severity: 'warning',
1694
+ category: 'layout',
1695
+ message: `Sibling cohort ${cohort.join(', ')} is not mirror-symmetric`,
1696
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1697
+ details: {
1698
+ focusIds: cohort,
1699
+ xCoordinates: nodes.map(({ x }) => x),
1700
+ centerTwice: mirrorCenterTwice,
1701
+ deviation: mirrorDeviation,
1702
+ },
1703
+ });
1704
+ }
1705
+ if (anchorDeviation > 0) {
1706
+ offAnchorSiblingCohortCount += 1;
1707
+ context.diagnostics.push({
1708
+ code: 'FOCUS_LAYOUT_SIBLING_ANCHOR_DEVIATION',
1709
+ severity: 'warning',
1710
+ category: 'layout',
1711
+ message: `Sibling cohort ${cohort.join(', ')} is offset from its structural anchor`,
1712
+ ...(focus?.sourceLocation === undefined ? {} : { location: focus.sourceLocation }),
1713
+ details: {
1714
+ focusIds: cohort,
1715
+ xCoordinates: nodes.map(({ x }) => x),
1716
+ centerTwice: anchorCenterTwice,
1717
+ anchorX,
1718
+ anchorKind,
1719
+ deviation: anchorDeviation,
1720
+ },
1721
+ });
1722
+ }
1723
+ }
1724
+ return {
1725
+ asymmetricSiblingCohortCount,
1726
+ totalSiblingDeviation,
1727
+ maximumSiblingDeviation,
1728
+ offAnchorSiblingCohortCount,
1729
+ totalSiblingAnchorDeviation,
1730
+ maximumSiblingAnchorDeviation,
1731
+ };
1732
+ }
1733
+ function layoutMetrics(context, edges, crossingCount, nodeIntersectionCount, symmetry, spacing) {
1734
+ const nodes = [...context.placed.values()];
1735
+ const xCoordinates = nodes.map(({ x }) => x);
1736
+ const yCoordinates = nodes.map(({ y }) => y);
1737
+ const minimumX = xCoordinates.length === 0 ? 0 : Math.min(...xCoordinates);
1738
+ const maximumX = xCoordinates.length === 0 ? 0 : Math.max(...xCoordinates);
1739
+ const minimumY = yCoordinates.length === 0 ? 0 : Math.min(...yCoordinates);
1740
+ const maximumY = yCoordinates.length === 0 ? 0 : Math.max(...yCoordinates);
1741
+ const spans = edges.map(connectorSpans);
1742
+ const sum = (values) => values.reduce((total, value) => total + value, 0);
1743
+ const maximum = (values) => values.length === 0 ? 0 : Math.max(...values);
1744
+ return {
1745
+ bounds: {
1746
+ minimumX,
1747
+ maximumX,
1748
+ minimumY,
1749
+ maximumY,
1750
+ columnSpan: maximumX - minimumX,
1751
+ rowSpan: maximumY - minimumY,
1752
+ columnCount: nodes.length === 0 ? 0 : maximumX - minimumX + 1,
1753
+ rowCount: nodes.length === 0 ? 0 : maximumY - minimumY + 1,
1754
+ },
1755
+ connectors: {
1756
+ count: edges.length,
1757
+ crossingCount,
1758
+ nodeIntersectionCount,
1759
+ longConnectorCount: edges.filter(longConnector).length,
1760
+ totalHorizontalSpan: sum(spans.map(({ horizontal }) => horizontal)),
1761
+ maximumHorizontalSpan: maximum(spans.map(({ horizontal }) => horizontal)),
1762
+ totalVerticalSpan: sum(spans.map(({ vertical }) => vertical)),
1763
+ maximumVerticalSpan: maximum(spans.map(({ vertical }) => vertical)),
1764
+ totalManhattanSpan: sum(spans.map(({ manhattan }) => manhattan)),
1765
+ maximumManhattanSpan: maximum(spans.map(({ manhattan }) => manhattan)),
1766
+ },
1767
+ symmetry: {
1768
+ siblingCohortCount: context.siblingCohorts.length,
1769
+ ...symmetry,
1770
+ boundingCenterOffsetTwice: Math.abs(minimumX + maximumX),
1771
+ },
1772
+ spacing: {
1773
+ requiredSameRowSpacing: context.nodeSpacing,
1774
+ ...spacing,
1775
+ },
1776
+ };
1777
+ }
629
1778
  function* parentOrderDiagnostics(context) {
630
1779
  let focusIndex = 0;
631
1780
  for (const focus of context.focuses.values()) {
@@ -717,23 +1866,28 @@ function* layoutFocusTreeSteps(plan, options = {}) {
717
1866
  }
718
1867
  const laneIds = new Map([...focuses.values()].map((focus) => [focus.id, laneFor(plan, focus)]));
719
1868
  const discoveredLanes = [...new Set(laneIds.values())].sort((left, right) => compareCodeUnits(left, right));
1869
+ const nodeSpacing = options.nodeSpacing ?? DEFAULT_NODE_SPACING;
1870
+ const siblingLayout = automaticSiblingLayout(plan, focuses, laneIds, nodeSpacing);
720
1871
  const context = {
721
1872
  plan,
722
1873
  focuses,
723
1874
  previous: new Map(options.previous?.nodes.map((node) => [node.id, node]) ?? []),
724
1875
  placed: new Map(),
725
1876
  occupied: new Map(),
1877
+ occupiedRows: new Map(),
726
1878
  connectorDefinitionsByFocus: connectorDefinitionsByFocus(focuses),
727
1879
  placedConnectors: new Map(),
728
1880
  mutualExclusions: mutualExclusionMap(focuses),
729
- laneBases: laneBases(plan, discoveredLanes, options.laneSpacing ?? 8),
1881
+ laneBases: laneBases(plan, discoveredLanes, options.laneSpacing ?? DEFAULT_LANE_SPACING),
730
1882
  laneBounds: laneBounds(plan),
731
1883
  laneIds,
732
- nodeSpacing: options.nodeSpacing ?? 2,
733
- minimumMutualExclusionSpacing: options.nodeSpacing ?? 2,
1884
+ siblingOffsets: siblingLayout.offsets,
1885
+ siblingCohorts: siblingLayout.cohorts,
1886
+ nodeSpacing,
1887
+ minimumMutualExclusionSpacing: nodeSpacing,
734
1888
  decisions: [],
735
1889
  diagnostics,
736
- work: new LayoutWorkBudget(),
1890
+ work: options.workBudget ?? new FocusLayoutWorkBudget(),
737
1891
  ...(options.signal === undefined ? {} : { signal: options.signal }),
738
1892
  };
739
1893
  const ordered = [...focuses.values()].sort((left, right) => {
@@ -750,11 +1904,20 @@ function* layoutFocusTreeSteps(plan, options = {}) {
750
1904
  yield* cancellationCheckpoint(options.signal, focusIndex, 8);
751
1905
  yield* placeFocus(context, focus.id);
752
1906
  }
1907
+ yield* repairConnectorCrossings(context);
1908
+ yield* refineConnectorQuality(context);
1909
+ centerFullyAutomaticLayout(context);
753
1910
  yield* parentOrderDiagnostics(context);
754
1911
  yield* laneBoundsDiagnostics(context);
755
1912
  yield* visibleOverlapDiagnostics(context);
1913
+ const spacing = yield* sameRowSpacingDiagnostics(context);
756
1914
  yield* mutualExclusionSpacingDiagnostics(context);
757
- yield* connectorCrossingDiagnostics(context);
1915
+ const edges = yield* connectorEdges(context);
1916
+ const crossingSummary = yield* connectorCrossings(edges, context);
1917
+ const nodeIntersectionCount = yield* connectorNodeIntersectionDiagnostics(context, edges);
1918
+ yield* connectorCrossingDiagnostics(context, crossingSummary.samples);
1919
+ yield* connectorLengthDiagnostics(context, edges);
1920
+ const symmetry = yield* siblingSymmetryDiagnostics(context);
758
1921
  const nodes = [...context.placed.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
759
1922
  const decisions = [...context.decisions].sort((left, right) => compareCodeUnits(left.focusId, right.focusId));
760
1923
  yield* cancellationCheckpoint(options.signal);
@@ -763,6 +1926,7 @@ function* layoutFocusTreeSteps(plan, options = {}) {
763
1926
  nodes,
764
1927
  decisions,
765
1928
  diagnostics: sortDiagnostics(context.diagnostics.values()),
1929
+ metrics: layoutMetrics(context, edges, crossingSummary.count, nodeIntersectionCount, symmetry, spacing),
766
1930
  layoutHash: hashCanonical(nodes.map(({ id, x, y, laneId }) => ({ id, laneId, x, y }))),
767
1931
  };
768
1932
  }