@crazyhappyone/auto-graph 0.1.3 → 0.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.
package/dist/cli/index.js CHANGED
@@ -1289,6 +1289,9 @@ function applyLayoutConstraints(input) {
1289
1289
  const nodeById = new Map(input.nodes.map((node) => [node.id, node]));
1290
1290
  applyFixedPositionLocks(input.nodes, boxes, locks, diagnostics);
1291
1291
  applyExactPositions(input.constraints, boxes, locks, diagnostics, nodeById);
1292
+ if (input.distributeContainedChildren) {
1293
+ yieldFixedPositionLocks(input, boxes, locks);
1294
+ }
1292
1295
  applyContainment(input.constraints, boxes, locks, diagnostics, false);
1293
1296
  applyRelative(input.constraints, boxes, locks, diagnostics);
1294
1297
  applyAlign(input.constraints, boxes, locks, diagnostics);
@@ -1302,6 +1305,13 @@ function applyLayoutConstraints(input) {
1302
1305
  );
1303
1306
  applyContainment(input.constraints, boxes, locks, diagnostics, true);
1304
1307
  applyDistributeContained(input, boxes, locks, diagnostics);
1308
+ if (input.distributeContainedChildren) {
1309
+ const diagBefore = diagnostics.length;
1310
+ applyContainment(input.constraints, boxes, locks, diagnostics, true);
1311
+ applyDistributeContained(input, boxes, locks, diagnostics);
1312
+ dedupReplayDiagnostics(diagnostics, diagBefore);
1313
+ }
1314
+ removeResolvedConstraintDiagnostics(input.constraints, boxes, diagnostics);
1305
1315
  reportOverlaps(boxes, diagnostics, containmentOverlapKeys(input.constraints));
1306
1316
  reportIntraContainerOverflow(input, boxes, diagnostics);
1307
1317
  return { boxes, locks, diagnostics };
@@ -1347,6 +1357,62 @@ function applyFixedPositionLocks(nodes, boxes, locks, diagnostics) {
1347
1357
  locks.set(node.id, { nodeId: node.id, source: "fixed-position" });
1348
1358
  }
1349
1359
  }
1360
+ function dedupReplayDiagnostics(diagnostics, keepUpTo) {
1361
+ const seen = /* @__PURE__ */ new Set();
1362
+ for (let i = 0; i < keepUpTo && i < diagnostics.length; i += 1) {
1363
+ const d = diagnostics[i];
1364
+ if (d === void 0) continue;
1365
+ seen.add(diagnosticFingerprint(d));
1366
+ }
1367
+ for (let i = diagnostics.length - 1; i >= keepUpTo; i -= 1) {
1368
+ const d = diagnostics[i];
1369
+ if (d === void 0) continue;
1370
+ const fp = diagnosticFingerprint(d);
1371
+ if (seen.has(fp)) {
1372
+ diagnostics.splice(i, 1);
1373
+ } else {
1374
+ seen.add(fp);
1375
+ }
1376
+ }
1377
+ }
1378
+ function diagnosticFingerprint(d) {
1379
+ const nodeId = typeof d.detail?.nodeId === "string" ? d.detail.nodeId : "";
1380
+ const containerId = typeof d.detail?.containerId === "string" ? d.detail.containerId : "";
1381
+ return `${d.code}|${nodeId}|${containerId}`;
1382
+ }
1383
+ function yieldFixedPositionLocks(input, boxes, locks) {
1384
+ for (const c of input.constraints) {
1385
+ if (c.kind !== "containment") continue;
1386
+ const container = boxes.get(c.containerId);
1387
+ if (container === void 0) continue;
1388
+ const content = contentBox(container, c.padding);
1389
+ const mainAxis = input.direction === "LR" || input.direction === "RL" ? "width" : "height";
1390
+ const crossAxis = mainAxis === "width" ? "height" : "width";
1391
+ let eligible = 0;
1392
+ for (const childId of c.childIds) {
1393
+ const box = boxes.get(childId);
1394
+ if (box === void 0) continue;
1395
+ const lock = locks.get(childId);
1396
+ if (lock?.source === "exact-position") continue;
1397
+ const fits = box[mainAxis] <= content[mainAxis] && box[crossAxis] <= content[crossAxis];
1398
+ if (fits) {
1399
+ eligible += 1;
1400
+ }
1401
+ }
1402
+ if (eligible < 2) continue;
1403
+ for (const childId of c.childIds) {
1404
+ const lock = locks.get(childId);
1405
+ if (lock?.source === "fixed-position") {
1406
+ const box = boxes.get(childId);
1407
+ if (box === void 0) continue;
1408
+ const fits = box[mainAxis] <= content[mainAxis] && box[crossAxis] <= content[crossAxis];
1409
+ if (fits) {
1410
+ locks.delete(childId);
1411
+ }
1412
+ }
1413
+ }
1414
+ }
1415
+ }
1350
1416
  function applyExactPositions(constraints, boxes, locks, diagnostics, nodeById) {
1351
1417
  for (const constraint of constraints) {
1352
1418
  if (constraint.kind !== "exact-position") {
@@ -1419,7 +1485,7 @@ function applyContainment(constraints, boxes, locks, diagnostics, reportOverflow
1419
1485
  code: "constraints.locked-target-not-moved",
1420
1486
  message: `Locked child ${childId} was not moved into containment.`,
1421
1487
  path: ["constraints", constraint.id ?? constraint.containerId],
1422
- detail: { nodeId: childId }
1488
+ detail: { nodeId: childId, containerId: constraint.containerId }
1423
1489
  });
1424
1490
  if (!isInside(child, content)) {
1425
1491
  diagnostics.push({
@@ -1571,6 +1637,60 @@ function repairOverlaps(input, boxes, locks, diagnostics, siblingPairs) {
1571
1637
  }
1572
1638
  reportOverlaps(boxes, diagnostics, ignoredPairs);
1573
1639
  }
1640
+ function removeResolvedConstraintDiagnostics(constraints, boxes, diagnostics) {
1641
+ for (let i = diagnostics.length - 1; i >= 0; i -= 1) {
1642
+ const d = diagnostics[i];
1643
+ if (d === void 0) continue;
1644
+ if (d.code === "constraints.overlap.unresolved") {
1645
+ const aId = d.detail?.firstId;
1646
+ const bId = d.detail?.secondId;
1647
+ if (typeof aId !== "string" || typeof bId !== "string") continue;
1648
+ const a = boxes.get(aId);
1649
+ const b = boxes.get(bId);
1650
+ if (a !== void 0 && b !== void 0 && !intersectsAabb(a, b)) {
1651
+ diagnostics.splice(i, 1);
1652
+ }
1653
+ continue;
1654
+ }
1655
+ if (d.code === "constraints.containment.impossible" || d.code === "constraints.locked-target-not-moved" && typeof d.message === "string" && d.message.includes("not moved into containment")) {
1656
+ const nodeId = d.detail?.nodeId;
1657
+ if (typeof nodeId !== "string") continue;
1658
+ const child = boxes.get(nodeId);
1659
+ if (child === void 0) continue;
1660
+ const diagContainerId = typeof d.detail?.containerId === "string" ? d.detail.containerId : void 0;
1661
+ let resolved = false;
1662
+ for (const c of constraints) {
1663
+ if (c.kind !== "containment") continue;
1664
+ if (!c.childIds.includes(nodeId)) continue;
1665
+ if (diagContainerId !== void 0 && c.containerId !== diagContainerId) {
1666
+ continue;
1667
+ }
1668
+ const container = boxes.get(c.containerId);
1669
+ if (container === void 0) continue;
1670
+ const content = contentBox(container, c.padding);
1671
+ if (isInside(child, content)) {
1672
+ diagnostics.splice(i, 1);
1673
+ resolved = true;
1674
+ }
1675
+ break;
1676
+ }
1677
+ if (!resolved && diagContainerId !== void 0) {
1678
+ for (const c of constraints) {
1679
+ if (c.kind !== "containment") continue;
1680
+ if (c.containerId !== diagContainerId) continue;
1681
+ if (!c.childIds.includes(nodeId)) continue;
1682
+ const container = boxes.get(c.containerId);
1683
+ if (container === void 0) continue;
1684
+ const content = contentBox(container, c.padding);
1685
+ if (isInside(child, content)) {
1686
+ diagnostics.splice(i, 1);
1687
+ }
1688
+ break;
1689
+ }
1690
+ }
1691
+ }
1692
+ }
1693
+ }
1574
1694
  function reportOverlaps(boxes, diagnostics, ignoredPairs = /* @__PURE__ */ new Set()) {
1575
1695
  const ids = [...boxes.keys()].sort();
1576
1696
  const reported = new Set(
@@ -1878,6 +1998,11 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
1878
1998
  continue;
1879
1999
  }
1880
2000
  if (locks.has(childId)) {
2001
+ const lock = locks.get(childId);
2002
+ if (lock?.source === "fixed-position") {
2003
+ unlocked.push({ id: childId, box });
2004
+ continue;
2005
+ }
1881
2006
  diagnostics.push({
1882
2007
  severity: "warning",
1883
2008
  code: "constraints.locked-target-not-moved",
@@ -1936,6 +2061,7 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
1936
2061
  });
1937
2062
  }
1938
2063
  boxes.set(child.id, clamped);
2064
+ locks.delete(child.id);
1939
2065
  pos = clamped[axis] + clamped[mainSize] + minGap;
1940
2066
  }
1941
2067
  diagnostics.push({
@@ -3251,6 +3377,213 @@ function isValidDimension(value) {
3251
3377
  return Number.isFinite(value) && value >= 0;
3252
3378
  }
3253
3379
 
3380
+ // src/routing/astar.ts
3381
+ function findObstacleFreePath(source, target, obstacles, options = {}) {
3382
+ const margin = options.margin ?? 0;
3383
+ const turnPenalty = options.turnPenalty ?? 50;
3384
+ const segmentPenalty = options.segmentPenalty ?? 1;
3385
+ const endpointObstacles = options.endpointObstacles ?? [];
3386
+ const maxNodes = options.maxNodes ?? 4e3;
3387
+ const xs = collectXs(source, target, obstacles, margin);
3388
+ const ys = collectYs(source, target, obstacles, margin);
3389
+ if (xs.length * ys.length > maxNodes) {
3390
+ return null;
3391
+ }
3392
+ const { nodes, nodeIndex } = buildGraph(xs, ys);
3393
+ connectHorizontalEdges(nodes, ys, obstacles, endpointObstacles, margin);
3394
+ connectVerticalEdges(nodes, xs, obstacles, endpointObstacles, margin);
3395
+ const path = aStarSearch(
3396
+ nodes,
3397
+ nodeIndex,
3398
+ source,
3399
+ target,
3400
+ turnPenalty,
3401
+ segmentPenalty
3402
+ );
3403
+ if (path === null) return null;
3404
+ return simplifyRoute(path);
3405
+ }
3406
+ function collectXs(source, target, obstacles, margin) {
3407
+ const set = /* @__PURE__ */ new Set();
3408
+ set.add(source.x);
3409
+ set.add(target.x);
3410
+ for (const obs of obstacles) {
3411
+ set.add(obs.x - margin - 2);
3412
+ set.add(obs.x + obs.width + margin + 2);
3413
+ }
3414
+ return [...set].sort((a, b) => a - b);
3415
+ }
3416
+ function collectYs(source, target, obstacles, margin) {
3417
+ const set = /* @__PURE__ */ new Set();
3418
+ set.add(source.y);
3419
+ set.add(target.y);
3420
+ for (const obs of obstacles) {
3421
+ set.add(obs.y - margin - 2);
3422
+ set.add(obs.y + obs.height + margin + 2);
3423
+ }
3424
+ return [...set].sort((a, b) => a - b);
3425
+ }
3426
+ function buildGraph(xs, ys) {
3427
+ const nodes = [];
3428
+ const nodeIndex = /* @__PURE__ */ new Map();
3429
+ for (let xi = 0; xi < xs.length; xi++) {
3430
+ for (let yi = 0; yi < ys.length; yi++) {
3431
+ const x = xs[xi];
3432
+ const y = ys[yi];
3433
+ const id = nodes.length;
3434
+ nodes.push({ x, y, id, neighbors: /* @__PURE__ */ new Map() });
3435
+ nodeIndex.set(`${x},${y}`, id);
3436
+ }
3437
+ }
3438
+ return { nodes, nodeIndex };
3439
+ }
3440
+ function connectHorizontalEdges(nodes, ys, obstacles, endpointObstacles, margin) {
3441
+ for (const y of ys) {
3442
+ const row = nodes.filter((n) => n.y === y).sort((a, b) => a.x - b.x);
3443
+ for (let i = 0; i < row.length - 1; i++) {
3444
+ const a = row[i];
3445
+ const b = row[i + 1];
3446
+ const dx = b.x - a.x;
3447
+ if (dx <= 0) continue;
3448
+ if (segmentCrossesAny(a, b, obstacles, endpointObstacles, margin)) {
3449
+ continue;
3450
+ }
3451
+ a.neighbors.set(b.id, dx);
3452
+ b.neighbors.set(a.id, dx);
3453
+ }
3454
+ }
3455
+ }
3456
+ function connectVerticalEdges(nodes, xs, obstacles, endpointObstacles, margin) {
3457
+ for (const x of xs) {
3458
+ const col = nodes.filter((n) => n.x === x).sort((a, b) => a.y - b.y);
3459
+ for (let i = 0; i < col.length - 1; i++) {
3460
+ const a = col[i];
3461
+ const b = col[i + 1];
3462
+ const dy = b.y - a.y;
3463
+ if (dy <= 0) continue;
3464
+ if (segmentCrossesAny(a, b, obstacles, endpointObstacles, margin)) {
3465
+ continue;
3466
+ }
3467
+ a.neighbors.set(b.id, dy);
3468
+ b.neighbors.set(a.id, dy);
3469
+ }
3470
+ }
3471
+ }
3472
+ function segmentCrossesAny(a, b, obstacles, endpointObstacles, margin) {
3473
+ for (const obs of obstacles) {
3474
+ if (segmentCrossesBoxStrict(a, b, obs, margin)) return true;
3475
+ }
3476
+ for (const ep of endpointObstacles) {
3477
+ if (segmentCrossesBoxStrict(a, b, ep, margin)) return true;
3478
+ }
3479
+ return false;
3480
+ }
3481
+ function segmentCrossesBoxStrict(start, end, box, margin) {
3482
+ const left = box.x - margin;
3483
+ const right = box.x + box.width + margin;
3484
+ const top = box.y - margin;
3485
+ const bottom = box.y + box.height + margin;
3486
+ if (pointInsideStrict(start, left, right, top, bottom)) return true;
3487
+ if (pointInsideStrict(end, left, right, top, bottom)) return true;
3488
+ if (start.x === end.x) {
3489
+ return start.x >= left && start.x <= right && rangesOverlap(start.y, end.y, top, bottom);
3490
+ }
3491
+ if (start.y === end.y) {
3492
+ return start.y >= top && start.y <= bottom && rangesOverlap(start.x, end.x, left, right);
3493
+ }
3494
+ return segmentEdgeIntersect(start, end, left, top, right, top) || segmentEdgeIntersect(start, end, right, top, right, bottom) || segmentEdgeIntersect(start, end, right, bottom, left, bottom) || segmentEdgeIntersect(start, end, left, bottom, left, top);
3495
+ }
3496
+ function pointInsideStrict(p, left, right, top, bottom) {
3497
+ return p.x > left && p.x < right && p.y > top && p.y < bottom;
3498
+ }
3499
+ function rangesOverlap(a, b, min, max) {
3500
+ const low = Math.min(a, b);
3501
+ const high = Math.max(a, b);
3502
+ return high > min && low < max;
3503
+ }
3504
+ function segmentEdgeIntersect(start, end, x1, y1, x2, y2) {
3505
+ const denominator = (end.x - start.x) * (y2 - y1) - (end.y - start.y) * (x2 - x1);
3506
+ if (denominator === 0) return false;
3507
+ const t = ((start.x - x1) * (y2 - y1) - (start.y - y1) * (x2 - x1)) / denominator;
3508
+ const u = ((start.x - x1) * (end.y - start.y) - (start.y - y1) * (end.x - start.x)) / denominator;
3509
+ return t > 0 && t < 1 && u > 0 && u < 1;
3510
+ }
3511
+ function aStarSearch(nodes, nodeIndex, source, target, turnPenalty, segmentPenalty) {
3512
+ const startId = nodeIndex.get(`${source.x},${source.y}`);
3513
+ const goalId = nodeIndex.get(`${target.x},${target.y}`);
3514
+ if (startId === void 0 || goalId === void 0) return null;
3515
+ const gScore = /* @__PURE__ */ new Map();
3516
+ gScore.set(startId, 0);
3517
+ const cameFrom = /* @__PURE__ */ new Map();
3518
+ const cameFromDir = /* @__PURE__ */ new Map();
3519
+ const openSet = [];
3520
+ openSet.push({
3521
+ id: startId,
3522
+ f: manhattan(source, target)
3523
+ });
3524
+ while (openSet.length > 0) {
3525
+ let bestIdx = 0;
3526
+ for (let i = 1; i < openSet.length; i++) {
3527
+ if (openSet[i].f < openSet[bestIdx].f) {
3528
+ bestIdx = i;
3529
+ }
3530
+ }
3531
+ const current = openSet.splice(bestIdx, 1)[0];
3532
+ if (current.id === goalId) {
3533
+ return reconstructPath(nodes, cameFrom, goalId);
3534
+ }
3535
+ const node = nodes[current.id];
3536
+ const currentG = gScore.get(current.id);
3537
+ const prevDir = cameFromDir.get(current.id);
3538
+ for (const [neighborId, edgeCost] of node.neighbors) {
3539
+ const neighbor = nodes[neighborId];
3540
+ const tentativeG = currentG + edgeCost * segmentPenalty;
3541
+ const newDir = neighbor.y === node.y ? "h" : "v";
3542
+ const turnCost = prevDir !== void 0 && prevDir !== newDir ? turnPenalty : 0;
3543
+ const totalG = tentativeG + turnCost;
3544
+ const existingG = gScore.get(neighborId);
3545
+ if (existingG === void 0 || totalG < existingG) {
3546
+ gScore.set(neighborId, totalG);
3547
+ cameFrom.set(neighborId, current.id);
3548
+ cameFromDir.set(neighborId, newDir);
3549
+ const f = totalG + manhattan(neighbor, target);
3550
+ openSet.push({ id: neighborId, f });
3551
+ }
3552
+ }
3553
+ }
3554
+ return null;
3555
+ }
3556
+ function manhattan(a, b) {
3557
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
3558
+ }
3559
+ function reconstructPath(nodes, cameFrom, goalId) {
3560
+ const path = [];
3561
+ let current = goalId;
3562
+ while (current !== void 0) {
3563
+ const node = nodes[current];
3564
+ path.unshift({ x: node.x, y: node.y });
3565
+ current = cameFrom.get(current);
3566
+ }
3567
+ return path;
3568
+ }
3569
+ function simplifyRoute(points) {
3570
+ if (points.length <= 2) return [...points];
3571
+ const result = [points[0]];
3572
+ for (let i = 1; i < points.length - 1; i++) {
3573
+ const prev = result[result.length - 1];
3574
+ const curr = points[i];
3575
+ const next = points[i + 1];
3576
+ if (!areCollinear(prev, curr, next)) {
3577
+ result.push(curr);
3578
+ }
3579
+ }
3580
+ result.push(points[points.length - 1]);
3581
+ return result;
3582
+ }
3583
+ function areCollinear(a, b, c) {
3584
+ return a.x === b.x && b.x === c.x || a.y === b.y && b.y === c.y;
3585
+ }
3586
+
3254
3587
  // src/routing/routes.ts
3255
3588
  function routeEdge(input) {
3256
3589
  const diagnostics = [];
@@ -3296,6 +3629,43 @@ function routeEdge(input) {
3296
3629
  }
3297
3630
  return { points, diagnostics };
3298
3631
  }
3632
+ if ((input.kind ?? "orthogonal") === "obstacle-avoiding") {
3633
+ const endpointObstacles = endpointObstaclesForAutoAnchors(input);
3634
+ for (const { sourceAnchor, targetAnchor } of routeAnchorPairs(
3635
+ input,
3636
+ defaultAnchors
3637
+ )) {
3638
+ const source = getEdgePort(
3639
+ input.source,
3640
+ input.target.center,
3641
+ sourceAnchor
3642
+ );
3643
+ const target = getEdgePort(
3644
+ input.target,
3645
+ input.source.center,
3646
+ targetAnchor
3647
+ );
3648
+ const path = findObstacleFreePath(
3649
+ source,
3650
+ target,
3651
+ [...softObstacles, ...hardObstacles],
3652
+ {
3653
+ endpointObstacles
3654
+ }
3655
+ );
3656
+ if (path !== null && path.length >= 2) {
3657
+ const finalized = finalizeRoute(
3658
+ path,
3659
+ softObstacles,
3660
+ hardObstacles,
3661
+ diagnostics
3662
+ );
3663
+ if (!routeIntersectsObstacles(finalized, softObstacles) && !routeIntersectsObstacles(finalized, hardObstacles)) {
3664
+ return { points: finalized, diagnostics };
3665
+ }
3666
+ }
3667
+ }
3668
+ }
3299
3669
  const routeLaneObstacles = [...softObstacles, ...hardObstacles];
3300
3670
  const anchorPairs = routeAnchorPairs(input, defaultAnchors);
3301
3671
  const candidateRoutes = anchorPairs.flatMap(
@@ -3385,7 +3755,7 @@ function routeEdge(input) {
3385
3755
  const rerouted = greedyRerouteAroundObstacles(
3386
3756
  bestPoints2,
3387
3757
  allObstacles,
3388
- Math.min(maxAttempts, 3)
3758
+ maxAttempts
3389
3759
  );
3390
3760
  const reroutedAvoidsEndpointInteriors = !routeIntersectsEndpointInteriors(
3391
3761
  rerouted,
@@ -3498,7 +3868,7 @@ function routeEdge(input) {
3498
3868
  };
3499
3869
  }
3500
3870
  function finalizeRoute(points, softObstacles, hardObstacles, diagnostics) {
3501
- const simplified = simplifyRoute(points);
3871
+ const simplified = simplifyRoute2(points);
3502
3872
  if (simplified.length >= 3) {
3503
3873
  return simplified;
3504
3874
  }
@@ -3786,7 +4156,7 @@ function squaredDistance2(a, b) {
3786
4156
  const dy = a.y - b.y;
3787
4157
  return dx * dx + dy * dy;
3788
4158
  }
3789
- function simplifyRoute(points) {
4159
+ function simplifyRoute2(points) {
3790
4160
  const withoutDuplicates = [];
3791
4161
  for (const point2 of points) {
3792
4162
  const previous = withoutDuplicates.at(-1);
@@ -3798,7 +4168,7 @@ function simplifyRoute(points) {
3798
4168
  for (const point2 of withoutDuplicates) {
3799
4169
  const previous = simplified.at(-1);
3800
4170
  const beforePrevious = simplified.at(-2);
3801
- if (previous !== void 0 && beforePrevious !== void 0 && areCollinear(beforePrevious, previous, point2)) {
4171
+ if (previous !== void 0 && beforePrevious !== void 0 && areCollinear2(beforePrevious, previous, point2)) {
3802
4172
  simplified[simplified.length - 1] = { ...point2 };
3803
4173
  } else {
3804
4174
  simplified.push({ ...point2 });
@@ -4013,17 +4383,17 @@ function segmentIntersectsBox(start, end, box) {
4013
4383
  return true;
4014
4384
  }
4015
4385
  if (start.x === end.x) {
4016
- return start.x > left && start.x < right && rangesOverlap(start.y, end.y, top, bottom);
4386
+ return start.x > left && start.x < right && rangesOverlap2(start.y, end.y, top, bottom);
4017
4387
  }
4018
4388
  if (start.y === end.y) {
4019
- return start.y > top && start.y < bottom && rangesOverlap(start.x, end.x, left, right);
4389
+ return start.y > top && start.y < bottom && rangesOverlap2(start.x, end.x, left, right);
4020
4390
  }
4021
4391
  return segmentIntersectsBoxEdge(start, end, left, top, right, top) || segmentIntersectsBoxEdge(start, end, right, top, right, bottom) || segmentIntersectsBoxEdge(start, end, right, bottom, left, bottom) || segmentIntersectsBoxEdge(start, end, left, bottom, left, top);
4022
4392
  }
4023
4393
  function pointInsideBox(point2, box) {
4024
4394
  return point2.x > box.x && point2.x < box.x + box.width && point2.y > box.y && point2.y < box.y + box.height;
4025
4395
  }
4026
- function rangesOverlap(a, b, min, max) {
4396
+ function rangesOverlap2(a, b, min, max) {
4027
4397
  const low = Math.min(a, b);
4028
4398
  const high = Math.max(a, b);
4029
4399
  return high > min && low < max;
@@ -4047,7 +4417,7 @@ function segmentBox(a, b) {
4047
4417
  height: Math.max(1, Math.abs(a.y - b.y))
4048
4418
  };
4049
4419
  }
4050
- function areCollinear(a, b, c) {
4420
+ function areCollinear2(a, b, c) {
4051
4421
  return a.x === b.x && b.x === c.x || a.y === b.y && b.y === c.y;
4052
4422
  }
4053
4423
 
@@ -4127,6 +4497,7 @@ function solveDiagram(diagram, options = {}) {
4127
4497
  options,
4128
4498
  diagnostics
4129
4499
  );
4500
+ expandNodeBoxesForPorts(styledNodes, initialNodeBoxes, options, diagnostics);
4130
4501
  const constrained = applyLayoutConstraints({
4131
4502
  direction: diagram.direction,
4132
4503
  overlapSpacing: options?.overlapSpacing ?? 40,
@@ -4147,9 +4518,7 @@ function solveDiagram(diagram, options = {}) {
4147
4518
  options?.overlapSpacing ?? 40,
4148
4519
  Math.max(0, options?.minLaneGutter ?? 0)
4149
4520
  );
4150
- if (swimlaneContracts.layouts.size > 0) {
4151
- removeResolvedOverlapDiagnostics(diagnostics, constrained.boxes);
4152
- }
4521
+ removeResolvedOverlapDiagnostics(diagnostics, constrained.boxes);
4153
4522
  diagnostics.push(...swimlaneContracts.diagnostics);
4154
4523
  const coordinatedNodes = coordinateNodes(
4155
4524
  styledNodes,
@@ -4192,6 +4561,11 @@ function solveDiagram(diagram, options = {}) {
4192
4561
  swimlanes: coordinatedSwimlanes,
4193
4562
  ...options.textMeasurer === void 0 ? {} : { textMeasurer: options.textMeasurer }
4194
4563
  });
4564
+ const edgeLabelEstimates = estimateEdgeLabelAnnotations(
4565
+ styledEdges,
4566
+ nodeGeometryById,
4567
+ options.textMeasurer
4568
+ );
4195
4569
  const layoutBoxes = [
4196
4570
  ...coordinatedNodes.map((node) => node.box),
4197
4571
  ...coordinatedNodes.flatMap(
@@ -4272,7 +4646,10 @@ function solveDiagram(diagram, options = {}) {
4272
4646
  const frameTextAnnotation = frame === void 0 ? [] : [coordinateFrameTextAnnotation(frame, options.textMeasurer)];
4273
4647
  const routingTextObstacles = [
4274
4648
  ...baseTextAnnotations.filter(isPreRouteTextObstacle),
4275
- ...frameTextAnnotation.filter(isPreRouteTextObstacle)
4649
+ ...frameTextAnnotation.filter(isPreRouteTextObstacle),
4650
+ // Dry-run edge-label estimates so edges route around
4651
+ // each other's label areas (Issue #41).
4652
+ ...edgeLabelEstimates
4276
4653
  ];
4277
4654
  const margin = options.obstacleMargin ?? 0;
4278
4655
  const softObstacles = [
@@ -4305,7 +4682,8 @@ function solveDiagram(diagram, options = {}) {
4305
4682
  hardObstacles,
4306
4683
  diagram.direction,
4307
4684
  options,
4308
- diagnostics
4685
+ diagnostics,
4686
+ coordinatedGroups
4309
4687
  );
4310
4688
  const edgeTextAnnotations = coordinateEdgeTextAnnotations(
4311
4689
  coordinatedEdges,
@@ -4418,22 +4796,7 @@ function expandLabelLayoutToNode(layout2, nodeSize) {
4418
4796
  y: layout2.box.y + offsetY,
4419
4797
  width: layout2.box.width,
4420
4798
  height: layout2.box.height
4421
- },
4422
- contentBox: {
4423
- x: layout2.contentBox.x + offsetX,
4424
- y: layout2.contentBox.y + offsetY,
4425
- width: layout2.contentBox.width,
4426
- height: layout2.contentBox.height
4427
- },
4428
- lines: layout2.lines.map((line) => ({
4429
- ...line,
4430
- box: {
4431
- x: line.box.x + offsetX,
4432
- y: line.box.y + offsetY,
4433
- width: line.box.width,
4434
- height: line.box.height
4435
- }
4436
- }))
4799
+ }
4437
4800
  };
4438
4801
  }
4439
4802
  function reportPageOverflow(contentBounds, pageBounds) {
@@ -5380,6 +5743,7 @@ function coordinateNodes(nodes, boxes, options, diagnostics) {
5380
5743
  });
5381
5744
  continue;
5382
5745
  }
5746
+ const ports = node.ports === void 0 ? void 0 : coordinatePorts(node, box, options.portShifting);
5383
5747
  const geometry = computeShapeGeometry({
5384
5748
  shape: node.shape,
5385
5749
  box,
@@ -5389,7 +5753,7 @@ function coordinateNodes(nodes, boxes, options, diagnostics) {
5389
5753
  id: node.id,
5390
5754
  ...node.label === void 0 ? {} : { label: node.label },
5391
5755
  ...node.style === void 0 ? {} : { style: node.style },
5392
- ...node.ports === void 0 ? {} : { ports: coordinatePorts(node, box, options.portShifting) },
5756
+ ...ports === void 0 ? {} : { ports },
5393
5757
  ...node.compartments === void 0 ? {} : { compartments: node.compartments },
5394
5758
  ...node.labelLayout === void 0 ? {} : { labelLayout: node.labelLayout },
5395
5759
  shape: node.shape,
@@ -5401,6 +5765,78 @@ function coordinateNodes(nodes, boxes, options, diagnostics) {
5401
5765
  }
5402
5766
  return coordinated;
5403
5767
  }
5768
+ var PORT_BOX_SIZE = 10;
5769
+ var MIN_PORT_EDGE_GAP = 12;
5770
+ function expandNodeBoxesForPorts(nodes, boxes, options, diagnostics) {
5771
+ const shiftingEnabled = options.portShifting?.enabled ?? true;
5772
+ if (!shiftingEnabled) return;
5773
+ const requestedSpacing = options.portShifting?.spacing ?? 24;
5774
+ const minSpacing = Math.max(
5775
+ requestedSpacing,
5776
+ PORT_BOX_SIZE + MIN_PORT_EDGE_GAP
5777
+ );
5778
+ for (const node of nodes) {
5779
+ if (node.ports === void 0 || node.ports.length === 0) continue;
5780
+ const box = boxes.get(node.id);
5781
+ if (box === void 0) continue;
5782
+ let heightExpansion = 0;
5783
+ let widthExpansion = 0;
5784
+ const portsBySide = /* @__PURE__ */ new Map();
5785
+ for (const port of node.ports) {
5786
+ const list = portsBySide.get(port.side) ?? [];
5787
+ list.push(port);
5788
+ portsBySide.set(port.side, list);
5789
+ }
5790
+ for (const [side, ports] of portsBySide) {
5791
+ const count = (ports ?? []).length;
5792
+ if (count <= 1) continue;
5793
+ const isVertical = side === "left" || side === "right";
5794
+ const availableSpan = isVertical ? box.height : box.width;
5795
+ const requiredSpan = (count - 1) * minSpacing + PORT_BOX_SIZE;
5796
+ if (requiredSpan > availableSpan) {
5797
+ const expansion = requiredSpan - availableSpan;
5798
+ if (isVertical) {
5799
+ heightExpansion = Math.max(heightExpansion, expansion);
5800
+ } else {
5801
+ widthExpansion = Math.max(widthExpansion, expansion);
5802
+ }
5803
+ diagnostics.push({
5804
+ severity: "info",
5805
+ code: "port_capacity_overflow",
5806
+ message: `Expanded node ${node.id} ${isVertical ? "height" : "width"} by ${Math.ceil(expansion)} px to fit ${count} port(s) on ${side} side.`,
5807
+ path: ["nodes", node.id, "ports"],
5808
+ detail: {
5809
+ nodeId: node.id,
5810
+ side,
5811
+ portCount: count,
5812
+ expansion: Math.ceil(expansion)
5813
+ }
5814
+ });
5815
+ }
5816
+ }
5817
+ if (heightExpansion > 0) {
5818
+ box.y -= heightExpansion / 2;
5819
+ box.height += heightExpansion;
5820
+ }
5821
+ if (widthExpansion > 0) {
5822
+ box.x -= widthExpansion / 2;
5823
+ box.width += widthExpansion;
5824
+ }
5825
+ if ((heightExpansion > 0 || widthExpansion > 0) && node.labelLayout !== void 0) {
5826
+ const layout2 = node.labelLayout;
5827
+ const newOffsetX = Math.max(0, (box.width - layout2.box.width) / 2);
5828
+ const newOffsetY = Math.max(0, (box.height - layout2.box.height) / 2);
5829
+ node.labelLayout = {
5830
+ ...layout2,
5831
+ box: {
5832
+ ...layout2.box,
5833
+ x: newOffsetX,
5834
+ y: newOffsetY
5835
+ }
5836
+ };
5837
+ }
5838
+ }
5839
+ }
5404
5840
  function coordinatePorts(node, nodeBox, portShifting) {
5405
5841
  const portsBySide = /* @__PURE__ */ new Map();
5406
5842
  for (const port of node.ports ?? []) {
@@ -5437,7 +5873,11 @@ function portAnchor(nodeBox, side, index, count, portShifting) {
5437
5873
  const requestedSpacing = portShifting?.spacing ?? 24;
5438
5874
  const maxOffset = side === "left" || side === "right" ? nodeBox.height / 2 : nodeBox.width / 2;
5439
5875
  const availableSpan = 2 * maxOffset;
5440
- const spacing = shiftingEnabled && count > 1 ? Math.min(requestedSpacing, availableSpan / (count - 1)) : requestedSpacing;
5876
+ const minSpacing = PORT_BOX_SIZE + MIN_PORT_EDGE_GAP;
5877
+ const spacing = shiftingEnabled && count > 1 ? Math.max(
5878
+ Math.min(requestedSpacing, availableSpan / (count - 1)),
5879
+ minSpacing
5880
+ ) : requestedSpacing;
5441
5881
  const centeredOffset = shiftingEnabled ? (index - (count - 1) / 2) * spacing : 0;
5442
5882
  switch (side) {
5443
5883
  case "left":
@@ -5463,7 +5903,7 @@ function portAnchor(nodeBox, side, index, count, portShifting) {
5463
5903
  }
5464
5904
  }
5465
5905
  function portBox(anchor) {
5466
- const size = 10;
5906
+ const size = PORT_BOX_SIZE;
5467
5907
  return {
5468
5908
  x: anchor.x - size / 2,
5469
5909
  y: anchor.y - size / 2,
@@ -6052,7 +6492,7 @@ function evidenceOverlapDiagnostic(block, conflict) {
6052
6492
  }
6053
6493
  };
6054
6494
  }
6055
- function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacles, textObstacles, hardObstacles, direction, options, diagnostics) {
6495
+ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacles, textObstacles, hardObstacles, direction, options, diagnostics, groups) {
6056
6496
  const coordinated = [];
6057
6497
  const coordinatedNodeById = new Map(
6058
6498
  coordinatedNodes.map((node) => [node.id, node])
@@ -6076,8 +6516,7 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
6076
6516
  }
6077
6517
  const sourcePort = coordinatedNodeById.get(edge.source.nodeId)?.ports?.find((port) => port.id === edge.source.portId);
6078
6518
  const targetPort = coordinatedNodeById.get(edge.target.nodeId)?.ports?.find((port) => port.id === edge.target.portId);
6079
- const connectedTextOwners = edgeConnectedTextOwnerIds(edge);
6080
- const routeTextObstacles = textObstacles.filter((annotation) => !connectedTextOwners.has(annotation.ownerId)).map((annotation) => annotation.box);
6519
+ const routeTextObstacles = textObstacles.filter((annotation) => !isEdgeConnectedTextAnnotation(edge, annotation)).map((annotation) => annotation.box);
6081
6520
  const route = routeEdge({
6082
6521
  kind: options.routeKind ?? "orthogonal",
6083
6522
  direction,
@@ -6090,9 +6529,11 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
6090
6529
  (obstacle) => obstacle !== source.obstacleBox && obstacle !== target.obstacleBox
6091
6530
  ),
6092
6531
  ...softObstacles,
6532
+ ...groupObstaclesForEdge(edge, groups, options.obstacleMargin ?? 0),
6093
6533
  ...routeTextObstacles
6094
6534
  ],
6095
- hardObstacles
6535
+ hardObstacles,
6536
+ ...options.maxRoutingAttempts === void 0 ? {} : { maxRoutingAttempts: options.maxRoutingAttempts }
6096
6537
  });
6097
6538
  diagnostics.push(
6098
6539
  ...route.diagnostics.map((diagnostic) => ({
@@ -6107,15 +6548,52 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
6107
6548
  }
6108
6549
  return coordinated;
6109
6550
  }
6110
- function edgeConnectedTextOwnerIds(edge) {
6111
- const owners = /* @__PURE__ */ new Set();
6112
- if (edge.source.portId !== void 0) {
6113
- owners.add(`${edge.source.nodeId}.${edge.source.portId}`);
6551
+ function isEdgeConnectedTextAnnotation(edge, annotation) {
6552
+ switch (annotation.surfaceKind) {
6553
+ case "edge-label":
6554
+ return annotation.ownerId === edge.id;
6555
+ case "node-label":
6556
+ case "compartment-row":
6557
+ return annotation.ownerId === edge.source.nodeId || annotation.ownerId === edge.target.nodeId;
6558
+ case "port-label":
6559
+ return edge.source.portId !== void 0 && annotation.ownerId === `${edge.source.nodeId}.${edge.source.portId}` || edge.target.portId !== void 0 && annotation.ownerId === `${edge.target.nodeId}.${edge.target.portId}`;
6560
+ case "group-label":
6561
+ case "swimlane-label":
6562
+ case "frame-title":
6563
+ return false;
6114
6564
  }
6115
- if (edge.target.portId !== void 0) {
6116
- owners.add(`${edge.target.nodeId}.${edge.target.portId}`);
6565
+ }
6566
+ function ancestorGroupIds(groups, nodeId) {
6567
+ const direct = /* @__PURE__ */ new Set();
6568
+ for (const group of groups) {
6569
+ if (group.nodeIds.includes(nodeId)) {
6570
+ direct.add(group.id);
6571
+ }
6572
+ }
6573
+ let previousSize = -1;
6574
+ const ancestors = new Set(direct);
6575
+ while (ancestors.size !== previousSize) {
6576
+ previousSize = ancestors.size;
6577
+ for (const group of groups) {
6578
+ for (const candidate of ancestors) {
6579
+ if (group.groupIds.includes(candidate)) {
6580
+ ancestors.add(group.id);
6581
+ break;
6582
+ }
6583
+ }
6584
+ }
6117
6585
  }
6118
- return owners;
6586
+ return ancestors;
6587
+ }
6588
+ function groupObstaclesForEdge(edge, groups, margin) {
6589
+ const sourceAncestors = ancestorGroupIds(groups, edge.source.nodeId);
6590
+ const targetAncestors = ancestorGroupIds(groups, edge.target.nodeId);
6591
+ return groups.filter((group) => {
6592
+ if (sourceAncestors.has(group.id) || targetAncestors.has(group.id)) {
6593
+ return false;
6594
+ }
6595
+ return true;
6596
+ }).map((group) => margin === 0 ? group.box : expandBox(group.box, margin));
6119
6597
  }
6120
6598
  function coordinateBaseTextAnnotations(input) {
6121
6599
  const measurer = input.textMeasurer ?? createDefaultTextMeasurer();
@@ -6303,6 +6781,55 @@ function coordinateEdgeTextAnnotations(edges, obstacleBoxes, textMeasurer, label
6303
6781
  }
6304
6782
  return annotations;
6305
6783
  }
6784
+ function estimateEdgeLabelAnnotations(edges, nodes, textMeasurer) {
6785
+ const measurer = textMeasurer ?? createDefaultTextMeasurer();
6786
+ const annotations = [];
6787
+ for (const edge of edges) {
6788
+ if (edge.label?.text === void 0) {
6789
+ continue;
6790
+ }
6791
+ const sourceGeom = nodes.get(edge.source.nodeId);
6792
+ const targetGeom = nodes.get(edge.target.nodeId);
6793
+ if (sourceGeom === void 0 || targetGeom === void 0) {
6794
+ continue;
6795
+ }
6796
+ const layout2 = fitLabel(
6797
+ edge.label.text,
6798
+ {
6799
+ font: typographyTextStyle(edge.label, {
6800
+ fontFamily: "Arial",
6801
+ fontSize: 12,
6802
+ lineHeight: 14
6803
+ }),
6804
+ padding: { top: 0, right: 0, bottom: 0, left: 0 },
6805
+ minSize: { width: 0, height: 0 },
6806
+ maxWidth: 200
6807
+ },
6808
+ measurer
6809
+ );
6810
+ const cx = (sourceGeom.center.x + targetGeom.center.x) / 2;
6811
+ const cy = (sourceGeom.center.y + targetGeom.center.y) / 2;
6812
+ const box = {
6813
+ x: cx - layout2.box.width / 2,
6814
+ y: cy - layout2.box.height / 2,
6815
+ width: layout2.box.width,
6816
+ height: layout2.box.height
6817
+ };
6818
+ annotations.push({
6819
+ text: layout2.text,
6820
+ ownerId: edge.id,
6821
+ surfaceKind: "edge-label",
6822
+ box,
6823
+ anchor: { x: cx, y: cy },
6824
+ paddings: layout2.padding,
6825
+ lines: layout2.lines,
6826
+ fontFamily: normalizeOutputFontFamily(layout2.font),
6827
+ fontSize: layout2.font.fontSize,
6828
+ textBackend: layout2.textBackend
6829
+ });
6830
+ }
6831
+ return annotations;
6832
+ }
6306
6833
  function coordinateFrameTextAnnotation(frame, textMeasurer) {
6307
6834
  const layout2 = fitLabel(
6308
6835
  frame.titleTab,
@@ -6423,9 +6950,8 @@ function reportRouteTextClearance(edges, annotations) {
6423
6950
  const diagnostics = [];
6424
6951
  const relevantAnnotations = annotations.filter(isRouteClearanceText);
6425
6952
  for (const edge of edges) {
6426
- const connectedTextOwners = edgeConnectedTextOwnerIds(edge);
6427
6953
  for (const annotation of relevantAnnotations) {
6428
- if (annotation.ownerId === edge.id || connectedTextOwners.has(annotation.ownerId)) {
6954
+ if (isEdgeConnectedTextAnnotation(edge, annotation)) {
6429
6955
  continue;
6430
6956
  }
6431
6957
  if (!routeIntersectsTextBox(edge.points, annotation.box)) {
@@ -6449,9 +6975,6 @@ function reportRouteTextClearance(edges, annotations) {
6449
6975
  return diagnostics;
6450
6976
  }
6451
6977
  function isPreRouteTextObstacle(annotation) {
6452
- if (annotation.surfaceKind === "edge-label") {
6453
- return false;
6454
- }
6455
6978
  return isRouteClearanceText(annotation);
6456
6979
  }
6457
6980
  function isRouteClearanceText(annotation) {
@@ -6462,8 +6985,9 @@ function isRouteClearanceText(annotation) {
6462
6985
  case "frame-title":
6463
6986
  return true;
6464
6987
  case "node-label":
6465
- case "group-label":
6466
6988
  case "compartment-row":
6989
+ return true;
6990
+ case "group-label":
6467
6991
  return textExtendsOutsideAnchor(annotation);
6468
6992
  }
6469
6993
  }
@@ -6496,17 +7020,17 @@ function segmentIntersectsBox2(start, end, box) {
6496
7020
  return true;
6497
7021
  }
6498
7022
  if (start.x === end.x) {
6499
- return start.x > left && start.x < right && rangesOverlap2(start.y, end.y, top, bottom);
7023
+ return start.x > left && start.x < right && rangesOverlap3(start.y, end.y, top, bottom);
6500
7024
  }
6501
7025
  if (start.y === end.y) {
6502
- return start.y > top && start.y < bottom && rangesOverlap2(start.x, end.x, left, right);
7026
+ return start.y > top && start.y < bottom && rangesOverlap3(start.x, end.x, left, right);
6503
7027
  }
6504
7028
  return segmentIntersectsBoxEdge2(start, end, left, top, right, top) || segmentIntersectsBoxEdge2(start, end, right, top, right, bottom) || segmentIntersectsBoxEdge2(start, end, right, bottom, left, bottom) || segmentIntersectsBoxEdge2(start, end, left, bottom, left, top);
6505
7029
  }
6506
7030
  function pointInsideBox2(point2, box) {
6507
7031
  return point2.x > box.x && point2.x < box.x + box.width && point2.y > box.y && point2.y < box.y + box.height;
6508
7032
  }
6509
- function rangesOverlap2(a, b, min, max) {
7033
+ function rangesOverlap3(a, b, min, max) {
6510
7034
  const low = Math.min(a, b);
6511
7035
  const high = Math.max(a, b);
6512
7036
  return high > min && low < max;
@@ -6575,7 +7099,8 @@ function edgeLabelAnchor(edge, layout2, edges, obstacleBoxes, placedLabelBoxes,
6575
7099
  for (const candidate of edgeLabelAnchorCandidates(
6576
7100
  edge.points,
6577
7101
  placement,
6578
- layout2
7102
+ layout2,
7103
+ baseOffset
6579
7104
  )) {
6580
7105
  const labelBox = {
6581
7106
  x: candidate.x - layout2.box.width / 2,
@@ -6607,8 +7132,8 @@ function edgeLabelAnchor(edge, layout2, edges, obstacleBoxes, placedLabelBoxes,
6607
7132
  }
6608
7133
  return placement;
6609
7134
  }
6610
- function edgeLabelAnchorCandidates(points, placement, layout2) {
6611
- const segment = labelSegmentOnPolyline(points);
7135
+ function edgeLabelAnchorCandidates(points, placement, layout2, baseOffset = 10) {
7136
+ const segment = labelSegmentOnPolyline(points, baseOffset);
6612
7137
  if (segment === void 0) {
6613
7138
  return [placement];
6614
7139
  }
@@ -6658,7 +7183,7 @@ function edgeLabelAnchorCandidates(points, placement, layout2) {
6658
7183
  }, 0);
6659
7184
  if (totalLen > 200) {
6660
7185
  for (const ratio of [0.25, 0.75]) {
6661
- const qp = labelPlacementAtRatio(points, ratio, totalLen);
7186
+ const qp = labelPlacementAtRatio(points, ratio, totalLen, baseOffset);
6662
7187
  if (qp !== void 0) {
6663
7188
  candidates.push(qp);
6664
7189
  const qTargetDist = totalLen * ratio;
@@ -6744,7 +7269,7 @@ function labelSegmentOnPolyline(points, baseOffset = 10) {
6744
7269
  if (last === void 0) {
6745
7270
  return void 0;
6746
7271
  }
6747
- const offset = labelOffset2(last);
7272
+ const offset = labelOffset2(last, baseOffset);
6748
7273
  return {
6749
7274
  start: last.start,
6750
7275
  end: last.end,
@@ -6766,7 +7291,7 @@ function nonZeroSegments2(points) {
6766
7291
  }
6767
7292
  return segments;
6768
7293
  }
6769
- function labelPlacementAtRatio(points, ratio, totalLength) {
7294
+ function labelPlacementAtRatio(points, ratio, totalLength, baseOffset = 10) {
6770
7295
  if (points.length < 2 || ratio < 0 || ratio > 1) {
6771
7296
  return void 0;
6772
7297
  }
@@ -6784,7 +7309,10 @@ function labelPlacementAtRatio(points, ratio, totalLength) {
6784
7309
  }
6785
7310
  if (travelled + segLen >= targetDist) {
6786
7311
  const t = (targetDist - travelled) / segLen;
6787
- const offset = labelOffset2({ start: prev, end: curr, length: segLen });
7312
+ const offset = labelOffset2(
7313
+ { start: prev, end: curr, length: segLen },
7314
+ baseOffset
7315
+ );
6788
7316
  return {
6789
7317
  x: prev.x + (curr.x - prev.x) * t + offset.x,
6790
7318
  y: prev.y + (curr.y - prev.y) * t + offset.y