@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.
@@ -1292,6 +1292,9 @@ function applyLayoutConstraints(input) {
1292
1292
  const nodeById = new Map(input.nodes.map((node) => [node.id, node]));
1293
1293
  applyFixedPositionLocks(input.nodes, boxes, locks, diagnostics);
1294
1294
  applyExactPositions(input.constraints, boxes, locks, diagnostics, nodeById);
1295
+ if (input.distributeContainedChildren) {
1296
+ yieldFixedPositionLocks(input, boxes, locks);
1297
+ }
1295
1298
  applyContainment(input.constraints, boxes, locks, diagnostics, false);
1296
1299
  applyRelative(input.constraints, boxes, locks, diagnostics);
1297
1300
  applyAlign(input.constraints, boxes, locks, diagnostics);
@@ -1305,6 +1308,13 @@ function applyLayoutConstraints(input) {
1305
1308
  );
1306
1309
  applyContainment(input.constraints, boxes, locks, diagnostics, true);
1307
1310
  applyDistributeContained(input, boxes, locks, diagnostics);
1311
+ if (input.distributeContainedChildren) {
1312
+ const diagBefore = diagnostics.length;
1313
+ applyContainment(input.constraints, boxes, locks, diagnostics, true);
1314
+ applyDistributeContained(input, boxes, locks, diagnostics);
1315
+ dedupReplayDiagnostics(diagnostics, diagBefore);
1316
+ }
1317
+ removeResolvedConstraintDiagnostics(input.constraints, boxes, diagnostics);
1308
1318
  reportOverlaps(boxes, diagnostics, containmentOverlapKeys(input.constraints));
1309
1319
  reportIntraContainerOverflow(input, boxes, diagnostics);
1310
1320
  return { boxes, locks, diagnostics };
@@ -1350,6 +1360,62 @@ function applyFixedPositionLocks(nodes, boxes, locks, diagnostics) {
1350
1360
  locks.set(node.id, { nodeId: node.id, source: "fixed-position" });
1351
1361
  }
1352
1362
  }
1363
+ function dedupReplayDiagnostics(diagnostics, keepUpTo) {
1364
+ const seen = /* @__PURE__ */ new Set();
1365
+ for (let i = 0; i < keepUpTo && i < diagnostics.length; i += 1) {
1366
+ const d = diagnostics[i];
1367
+ if (d === void 0) continue;
1368
+ seen.add(diagnosticFingerprint(d));
1369
+ }
1370
+ for (let i = diagnostics.length - 1; i >= keepUpTo; i -= 1) {
1371
+ const d = diagnostics[i];
1372
+ if (d === void 0) continue;
1373
+ const fp = diagnosticFingerprint(d);
1374
+ if (seen.has(fp)) {
1375
+ diagnostics.splice(i, 1);
1376
+ } else {
1377
+ seen.add(fp);
1378
+ }
1379
+ }
1380
+ }
1381
+ function diagnosticFingerprint(d) {
1382
+ const nodeId = typeof d.detail?.nodeId === "string" ? d.detail.nodeId : "";
1383
+ const containerId = typeof d.detail?.containerId === "string" ? d.detail.containerId : "";
1384
+ return `${d.code}|${nodeId}|${containerId}`;
1385
+ }
1386
+ function yieldFixedPositionLocks(input, boxes, locks) {
1387
+ for (const c of input.constraints) {
1388
+ if (c.kind !== "containment") continue;
1389
+ const container = boxes.get(c.containerId);
1390
+ if (container === void 0) continue;
1391
+ const content = contentBox(container, c.padding);
1392
+ const mainAxis = input.direction === "LR" || input.direction === "RL" ? "width" : "height";
1393
+ const crossAxis = mainAxis === "width" ? "height" : "width";
1394
+ let eligible = 0;
1395
+ for (const childId of c.childIds) {
1396
+ const box = boxes.get(childId);
1397
+ if (box === void 0) continue;
1398
+ const lock = locks.get(childId);
1399
+ if (lock?.source === "exact-position") continue;
1400
+ const fits = box[mainAxis] <= content[mainAxis] && box[crossAxis] <= content[crossAxis];
1401
+ if (fits) {
1402
+ eligible += 1;
1403
+ }
1404
+ }
1405
+ if (eligible < 2) continue;
1406
+ for (const childId of c.childIds) {
1407
+ const lock = locks.get(childId);
1408
+ if (lock?.source === "fixed-position") {
1409
+ const box = boxes.get(childId);
1410
+ if (box === void 0) continue;
1411
+ const fits = box[mainAxis] <= content[mainAxis] && box[crossAxis] <= content[crossAxis];
1412
+ if (fits) {
1413
+ locks.delete(childId);
1414
+ }
1415
+ }
1416
+ }
1417
+ }
1418
+ }
1353
1419
  function applyExactPositions(constraints, boxes, locks, diagnostics, nodeById) {
1354
1420
  for (const constraint of constraints) {
1355
1421
  if (constraint.kind !== "exact-position") {
@@ -1422,7 +1488,7 @@ function applyContainment(constraints, boxes, locks, diagnostics, reportOverflow
1422
1488
  code: "constraints.locked-target-not-moved",
1423
1489
  message: `Locked child ${childId} was not moved into containment.`,
1424
1490
  path: ["constraints", constraint.id ?? constraint.containerId],
1425
- detail: { nodeId: childId }
1491
+ detail: { nodeId: childId, containerId: constraint.containerId }
1426
1492
  });
1427
1493
  if (!isInside(child, content)) {
1428
1494
  diagnostics.push({
@@ -1574,6 +1640,60 @@ function repairOverlaps(input, boxes, locks, diagnostics, siblingPairs) {
1574
1640
  }
1575
1641
  reportOverlaps(boxes, diagnostics, ignoredPairs);
1576
1642
  }
1643
+ function removeResolvedConstraintDiagnostics(constraints, boxes, diagnostics) {
1644
+ for (let i = diagnostics.length - 1; i >= 0; i -= 1) {
1645
+ const d = diagnostics[i];
1646
+ if (d === void 0) continue;
1647
+ if (d.code === "constraints.overlap.unresolved") {
1648
+ const aId = d.detail?.firstId;
1649
+ const bId = d.detail?.secondId;
1650
+ if (typeof aId !== "string" || typeof bId !== "string") continue;
1651
+ const a = boxes.get(aId);
1652
+ const b = boxes.get(bId);
1653
+ if (a !== void 0 && b !== void 0 && !intersectsAabb(a, b)) {
1654
+ diagnostics.splice(i, 1);
1655
+ }
1656
+ continue;
1657
+ }
1658
+ if (d.code === "constraints.containment.impossible" || d.code === "constraints.locked-target-not-moved" && typeof d.message === "string" && d.message.includes("not moved into containment")) {
1659
+ const nodeId = d.detail?.nodeId;
1660
+ if (typeof nodeId !== "string") continue;
1661
+ const child = boxes.get(nodeId);
1662
+ if (child === void 0) continue;
1663
+ const diagContainerId = typeof d.detail?.containerId === "string" ? d.detail.containerId : void 0;
1664
+ let resolved = false;
1665
+ for (const c of constraints) {
1666
+ if (c.kind !== "containment") continue;
1667
+ if (!c.childIds.includes(nodeId)) continue;
1668
+ if (diagContainerId !== void 0 && c.containerId !== diagContainerId) {
1669
+ continue;
1670
+ }
1671
+ const container = boxes.get(c.containerId);
1672
+ if (container === void 0) continue;
1673
+ const content = contentBox(container, c.padding);
1674
+ if (isInside(child, content)) {
1675
+ diagnostics.splice(i, 1);
1676
+ resolved = true;
1677
+ }
1678
+ break;
1679
+ }
1680
+ if (!resolved && diagContainerId !== void 0) {
1681
+ for (const c of constraints) {
1682
+ if (c.kind !== "containment") continue;
1683
+ if (c.containerId !== diagContainerId) continue;
1684
+ if (!c.childIds.includes(nodeId)) continue;
1685
+ const container = boxes.get(c.containerId);
1686
+ if (container === void 0) continue;
1687
+ const content = contentBox(container, c.padding);
1688
+ if (isInside(child, content)) {
1689
+ diagnostics.splice(i, 1);
1690
+ }
1691
+ break;
1692
+ }
1693
+ }
1694
+ }
1695
+ }
1696
+ }
1577
1697
  function reportOverlaps(boxes, diagnostics, ignoredPairs = /* @__PURE__ */ new Set()) {
1578
1698
  const ids = [...boxes.keys()].sort();
1579
1699
  const reported = new Set(
@@ -1881,6 +2001,11 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
1881
2001
  continue;
1882
2002
  }
1883
2003
  if (locks.has(childId)) {
2004
+ const lock = locks.get(childId);
2005
+ if (lock?.source === "fixed-position") {
2006
+ unlocked.push({ id: childId, box });
2007
+ continue;
2008
+ }
1884
2009
  diagnostics.push({
1885
2010
  severity: "warning",
1886
2011
  code: "constraints.locked-target-not-moved",
@@ -1939,6 +2064,7 @@ function applyDistributeContained(input, boxes, locks, diagnostics) {
1939
2064
  });
1940
2065
  }
1941
2066
  boxes.set(child.id, clamped);
2067
+ locks.delete(child.id);
1942
2068
  pos = clamped[axis] + clamped[mainSize] + minGap;
1943
2069
  }
1944
2070
  diagnostics.push({
@@ -3254,6 +3380,213 @@ function isValidDimension(value) {
3254
3380
  return Number.isFinite(value) && value >= 0;
3255
3381
  }
3256
3382
 
3383
+ // src/routing/astar.ts
3384
+ function findObstacleFreePath(source, target, obstacles, options = {}) {
3385
+ const margin = options.margin ?? 0;
3386
+ const turnPenalty = options.turnPenalty ?? 50;
3387
+ const segmentPenalty = options.segmentPenalty ?? 1;
3388
+ const endpointObstacles = options.endpointObstacles ?? [];
3389
+ const maxNodes = options.maxNodes ?? 4e3;
3390
+ const xs = collectXs(source, target, obstacles, margin);
3391
+ const ys = collectYs(source, target, obstacles, margin);
3392
+ if (xs.length * ys.length > maxNodes) {
3393
+ return null;
3394
+ }
3395
+ const { nodes, nodeIndex } = buildGraph(xs, ys);
3396
+ connectHorizontalEdges(nodes, ys, obstacles, endpointObstacles, margin);
3397
+ connectVerticalEdges(nodes, xs, obstacles, endpointObstacles, margin);
3398
+ const path = aStarSearch(
3399
+ nodes,
3400
+ nodeIndex,
3401
+ source,
3402
+ target,
3403
+ turnPenalty,
3404
+ segmentPenalty
3405
+ );
3406
+ if (path === null) return null;
3407
+ return simplifyRoute(path);
3408
+ }
3409
+ function collectXs(source, target, obstacles, margin) {
3410
+ const set = /* @__PURE__ */ new Set();
3411
+ set.add(source.x);
3412
+ set.add(target.x);
3413
+ for (const obs of obstacles) {
3414
+ set.add(obs.x - margin - 2);
3415
+ set.add(obs.x + obs.width + margin + 2);
3416
+ }
3417
+ return [...set].sort((a, b) => a - b);
3418
+ }
3419
+ function collectYs(source, target, obstacles, margin) {
3420
+ const set = /* @__PURE__ */ new Set();
3421
+ set.add(source.y);
3422
+ set.add(target.y);
3423
+ for (const obs of obstacles) {
3424
+ set.add(obs.y - margin - 2);
3425
+ set.add(obs.y + obs.height + margin + 2);
3426
+ }
3427
+ return [...set].sort((a, b) => a - b);
3428
+ }
3429
+ function buildGraph(xs, ys) {
3430
+ const nodes = [];
3431
+ const nodeIndex = /* @__PURE__ */ new Map();
3432
+ for (let xi = 0; xi < xs.length; xi++) {
3433
+ for (let yi = 0; yi < ys.length; yi++) {
3434
+ const x = xs[xi];
3435
+ const y = ys[yi];
3436
+ const id = nodes.length;
3437
+ nodes.push({ x, y, id, neighbors: /* @__PURE__ */ new Map() });
3438
+ nodeIndex.set(`${x},${y}`, id);
3439
+ }
3440
+ }
3441
+ return { nodes, nodeIndex };
3442
+ }
3443
+ function connectHorizontalEdges(nodes, ys, obstacles, endpointObstacles, margin) {
3444
+ for (const y of ys) {
3445
+ const row = nodes.filter((n) => n.y === y).sort((a, b) => a.x - b.x);
3446
+ for (let i = 0; i < row.length - 1; i++) {
3447
+ const a = row[i];
3448
+ const b = row[i + 1];
3449
+ const dx = b.x - a.x;
3450
+ if (dx <= 0) continue;
3451
+ if (segmentCrossesAny(a, b, obstacles, endpointObstacles, margin)) {
3452
+ continue;
3453
+ }
3454
+ a.neighbors.set(b.id, dx);
3455
+ b.neighbors.set(a.id, dx);
3456
+ }
3457
+ }
3458
+ }
3459
+ function connectVerticalEdges(nodes, xs, obstacles, endpointObstacles, margin) {
3460
+ for (const x of xs) {
3461
+ const col = nodes.filter((n) => n.x === x).sort((a, b) => a.y - b.y);
3462
+ for (let i = 0; i < col.length - 1; i++) {
3463
+ const a = col[i];
3464
+ const b = col[i + 1];
3465
+ const dy = b.y - a.y;
3466
+ if (dy <= 0) continue;
3467
+ if (segmentCrossesAny(a, b, obstacles, endpointObstacles, margin)) {
3468
+ continue;
3469
+ }
3470
+ a.neighbors.set(b.id, dy);
3471
+ b.neighbors.set(a.id, dy);
3472
+ }
3473
+ }
3474
+ }
3475
+ function segmentCrossesAny(a, b, obstacles, endpointObstacles, margin) {
3476
+ for (const obs of obstacles) {
3477
+ if (segmentCrossesBoxStrict(a, b, obs, margin)) return true;
3478
+ }
3479
+ for (const ep of endpointObstacles) {
3480
+ if (segmentCrossesBoxStrict(a, b, ep, margin)) return true;
3481
+ }
3482
+ return false;
3483
+ }
3484
+ function segmentCrossesBoxStrict(start, end, box, margin) {
3485
+ const left = box.x - margin;
3486
+ const right = box.x + box.width + margin;
3487
+ const top = box.y - margin;
3488
+ const bottom = box.y + box.height + margin;
3489
+ if (pointInsideStrict(start, left, right, top, bottom)) return true;
3490
+ if (pointInsideStrict(end, left, right, top, bottom)) return true;
3491
+ if (start.x === end.x) {
3492
+ return start.x >= left && start.x <= right && rangesOverlap(start.y, end.y, top, bottom);
3493
+ }
3494
+ if (start.y === end.y) {
3495
+ return start.y >= top && start.y <= bottom && rangesOverlap(start.x, end.x, left, right);
3496
+ }
3497
+ 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);
3498
+ }
3499
+ function pointInsideStrict(p, left, right, top, bottom) {
3500
+ return p.x > left && p.x < right && p.y > top && p.y < bottom;
3501
+ }
3502
+ function rangesOverlap(a, b, min, max) {
3503
+ const low = Math.min(a, b);
3504
+ const high = Math.max(a, b);
3505
+ return high > min && low < max;
3506
+ }
3507
+ function segmentEdgeIntersect(start, end, x1, y1, x2, y2) {
3508
+ const denominator = (end.x - start.x) * (y2 - y1) - (end.y - start.y) * (x2 - x1);
3509
+ if (denominator === 0) return false;
3510
+ const t = ((start.x - x1) * (y2 - y1) - (start.y - y1) * (x2 - x1)) / denominator;
3511
+ const u = ((start.x - x1) * (end.y - start.y) - (start.y - y1) * (end.x - start.x)) / denominator;
3512
+ return t > 0 && t < 1 && u > 0 && u < 1;
3513
+ }
3514
+ function aStarSearch(nodes, nodeIndex, source, target, turnPenalty, segmentPenalty) {
3515
+ const startId = nodeIndex.get(`${source.x},${source.y}`);
3516
+ const goalId = nodeIndex.get(`${target.x},${target.y}`);
3517
+ if (startId === void 0 || goalId === void 0) return null;
3518
+ const gScore = /* @__PURE__ */ new Map();
3519
+ gScore.set(startId, 0);
3520
+ const cameFrom = /* @__PURE__ */ new Map();
3521
+ const cameFromDir = /* @__PURE__ */ new Map();
3522
+ const openSet = [];
3523
+ openSet.push({
3524
+ id: startId,
3525
+ f: manhattan(source, target)
3526
+ });
3527
+ while (openSet.length > 0) {
3528
+ let bestIdx = 0;
3529
+ for (let i = 1; i < openSet.length; i++) {
3530
+ if (openSet[i].f < openSet[bestIdx].f) {
3531
+ bestIdx = i;
3532
+ }
3533
+ }
3534
+ const current = openSet.splice(bestIdx, 1)[0];
3535
+ if (current.id === goalId) {
3536
+ return reconstructPath(nodes, cameFrom, goalId);
3537
+ }
3538
+ const node = nodes[current.id];
3539
+ const currentG = gScore.get(current.id);
3540
+ const prevDir = cameFromDir.get(current.id);
3541
+ for (const [neighborId, edgeCost] of node.neighbors) {
3542
+ const neighbor = nodes[neighborId];
3543
+ const tentativeG = currentG + edgeCost * segmentPenalty;
3544
+ const newDir = neighbor.y === node.y ? "h" : "v";
3545
+ const turnCost = prevDir !== void 0 && prevDir !== newDir ? turnPenalty : 0;
3546
+ const totalG = tentativeG + turnCost;
3547
+ const existingG = gScore.get(neighborId);
3548
+ if (existingG === void 0 || totalG < existingG) {
3549
+ gScore.set(neighborId, totalG);
3550
+ cameFrom.set(neighborId, current.id);
3551
+ cameFromDir.set(neighborId, newDir);
3552
+ const f = totalG + manhattan(neighbor, target);
3553
+ openSet.push({ id: neighborId, f });
3554
+ }
3555
+ }
3556
+ }
3557
+ return null;
3558
+ }
3559
+ function manhattan(a, b) {
3560
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
3561
+ }
3562
+ function reconstructPath(nodes, cameFrom, goalId) {
3563
+ const path = [];
3564
+ let current = goalId;
3565
+ while (current !== void 0) {
3566
+ const node = nodes[current];
3567
+ path.unshift({ x: node.x, y: node.y });
3568
+ current = cameFrom.get(current);
3569
+ }
3570
+ return path;
3571
+ }
3572
+ function simplifyRoute(points) {
3573
+ if (points.length <= 2) return [...points];
3574
+ const result = [points[0]];
3575
+ for (let i = 1; i < points.length - 1; i++) {
3576
+ const prev = result[result.length - 1];
3577
+ const curr = points[i];
3578
+ const next = points[i + 1];
3579
+ if (!areCollinear(prev, curr, next)) {
3580
+ result.push(curr);
3581
+ }
3582
+ }
3583
+ result.push(points[points.length - 1]);
3584
+ return result;
3585
+ }
3586
+ function areCollinear(a, b, c) {
3587
+ return a.x === b.x && b.x === c.x || a.y === b.y && b.y === c.y;
3588
+ }
3589
+
3257
3590
  // src/routing/routes.ts
3258
3591
  function routeEdge(input) {
3259
3592
  const diagnostics = [];
@@ -3299,6 +3632,43 @@ function routeEdge(input) {
3299
3632
  }
3300
3633
  return { points, diagnostics };
3301
3634
  }
3635
+ if ((input.kind ?? "orthogonal") === "obstacle-avoiding") {
3636
+ const endpointObstacles = endpointObstaclesForAutoAnchors(input);
3637
+ for (const { sourceAnchor, targetAnchor } of routeAnchorPairs(
3638
+ input,
3639
+ defaultAnchors
3640
+ )) {
3641
+ const source = getEdgePort(
3642
+ input.source,
3643
+ input.target.center,
3644
+ sourceAnchor
3645
+ );
3646
+ const target = getEdgePort(
3647
+ input.target,
3648
+ input.source.center,
3649
+ targetAnchor
3650
+ );
3651
+ const path = findObstacleFreePath(
3652
+ source,
3653
+ target,
3654
+ [...softObstacles, ...hardObstacles],
3655
+ {
3656
+ endpointObstacles
3657
+ }
3658
+ );
3659
+ if (path !== null && path.length >= 2) {
3660
+ const finalized = finalizeRoute(
3661
+ path,
3662
+ softObstacles,
3663
+ hardObstacles,
3664
+ diagnostics
3665
+ );
3666
+ if (!routeIntersectsObstacles(finalized, softObstacles) && !routeIntersectsObstacles(finalized, hardObstacles)) {
3667
+ return { points: finalized, diagnostics };
3668
+ }
3669
+ }
3670
+ }
3671
+ }
3302
3672
  const routeLaneObstacles = [...softObstacles, ...hardObstacles];
3303
3673
  const anchorPairs = routeAnchorPairs(input, defaultAnchors);
3304
3674
  const candidateRoutes = anchorPairs.flatMap(
@@ -3388,7 +3758,7 @@ function routeEdge(input) {
3388
3758
  const rerouted = greedyRerouteAroundObstacles(
3389
3759
  bestPoints2,
3390
3760
  allObstacles,
3391
- Math.min(maxAttempts, 3)
3761
+ maxAttempts
3392
3762
  );
3393
3763
  const reroutedAvoidsEndpointInteriors = !routeIntersectsEndpointInteriors(
3394
3764
  rerouted,
@@ -3501,7 +3871,7 @@ function routeEdge(input) {
3501
3871
  };
3502
3872
  }
3503
3873
  function finalizeRoute(points, softObstacles, hardObstacles, diagnostics) {
3504
- const simplified = simplifyRoute(points);
3874
+ const simplified = simplifyRoute2(points);
3505
3875
  if (simplified.length >= 3) {
3506
3876
  return simplified;
3507
3877
  }
@@ -3789,7 +4159,7 @@ function squaredDistance2(a, b) {
3789
4159
  const dy = a.y - b.y;
3790
4160
  return dx * dx + dy * dy;
3791
4161
  }
3792
- function simplifyRoute(points) {
4162
+ function simplifyRoute2(points) {
3793
4163
  const withoutDuplicates = [];
3794
4164
  for (const point2 of points) {
3795
4165
  const previous = withoutDuplicates.at(-1);
@@ -3801,7 +4171,7 @@ function simplifyRoute(points) {
3801
4171
  for (const point2 of withoutDuplicates) {
3802
4172
  const previous = simplified.at(-1);
3803
4173
  const beforePrevious = simplified.at(-2);
3804
- if (previous !== void 0 && beforePrevious !== void 0 && areCollinear(beforePrevious, previous, point2)) {
4174
+ if (previous !== void 0 && beforePrevious !== void 0 && areCollinear2(beforePrevious, previous, point2)) {
3805
4175
  simplified[simplified.length - 1] = { ...point2 };
3806
4176
  } else {
3807
4177
  simplified.push({ ...point2 });
@@ -4016,17 +4386,17 @@ function segmentIntersectsBox(start, end, box) {
4016
4386
  return true;
4017
4387
  }
4018
4388
  if (start.x === end.x) {
4019
- return start.x > left && start.x < right && rangesOverlap(start.y, end.y, top, bottom);
4389
+ return start.x > left && start.x < right && rangesOverlap2(start.y, end.y, top, bottom);
4020
4390
  }
4021
4391
  if (start.y === end.y) {
4022
- return start.y > top && start.y < bottom && rangesOverlap(start.x, end.x, left, right);
4392
+ return start.y > top && start.y < bottom && rangesOverlap2(start.x, end.x, left, right);
4023
4393
  }
4024
4394
  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);
4025
4395
  }
4026
4396
  function pointInsideBox(point2, box) {
4027
4397
  return point2.x > box.x && point2.x < box.x + box.width && point2.y > box.y && point2.y < box.y + box.height;
4028
4398
  }
4029
- function rangesOverlap(a, b, min, max) {
4399
+ function rangesOverlap2(a, b, min, max) {
4030
4400
  const low = Math.min(a, b);
4031
4401
  const high = Math.max(a, b);
4032
4402
  return high > min && low < max;
@@ -4050,7 +4420,7 @@ function segmentBox(a, b) {
4050
4420
  height: Math.max(1, Math.abs(a.y - b.y))
4051
4421
  };
4052
4422
  }
4053
- function areCollinear(a, b, c) {
4423
+ function areCollinear2(a, b, c) {
4054
4424
  return a.x === b.x && b.x === c.x || a.y === b.y && b.y === c.y;
4055
4425
  }
4056
4426
 
@@ -4130,6 +4500,7 @@ function solveDiagram(diagram, options = {}) {
4130
4500
  options,
4131
4501
  diagnostics
4132
4502
  );
4503
+ expandNodeBoxesForPorts(styledNodes, initialNodeBoxes, options, diagnostics);
4133
4504
  const constrained = applyLayoutConstraints({
4134
4505
  direction: diagram.direction,
4135
4506
  overlapSpacing: options?.overlapSpacing ?? 40,
@@ -4150,9 +4521,7 @@ function solveDiagram(diagram, options = {}) {
4150
4521
  options?.overlapSpacing ?? 40,
4151
4522
  Math.max(0, options?.minLaneGutter ?? 0)
4152
4523
  );
4153
- if (swimlaneContracts.layouts.size > 0) {
4154
- removeResolvedOverlapDiagnostics(diagnostics, constrained.boxes);
4155
- }
4524
+ removeResolvedOverlapDiagnostics(diagnostics, constrained.boxes);
4156
4525
  diagnostics.push(...swimlaneContracts.diagnostics);
4157
4526
  const coordinatedNodes = coordinateNodes(
4158
4527
  styledNodes,
@@ -4195,6 +4564,11 @@ function solveDiagram(diagram, options = {}) {
4195
4564
  swimlanes: coordinatedSwimlanes,
4196
4565
  ...options.textMeasurer === void 0 ? {} : { textMeasurer: options.textMeasurer }
4197
4566
  });
4567
+ const edgeLabelEstimates = estimateEdgeLabelAnnotations(
4568
+ styledEdges,
4569
+ nodeGeometryById,
4570
+ options.textMeasurer
4571
+ );
4198
4572
  const layoutBoxes = [
4199
4573
  ...coordinatedNodes.map((node) => node.box),
4200
4574
  ...coordinatedNodes.flatMap(
@@ -4275,7 +4649,10 @@ function solveDiagram(diagram, options = {}) {
4275
4649
  const frameTextAnnotation = frame === void 0 ? [] : [coordinateFrameTextAnnotation(frame, options.textMeasurer)];
4276
4650
  const routingTextObstacles = [
4277
4651
  ...baseTextAnnotations.filter(isPreRouteTextObstacle),
4278
- ...frameTextAnnotation.filter(isPreRouteTextObstacle)
4652
+ ...frameTextAnnotation.filter(isPreRouteTextObstacle),
4653
+ // Dry-run edge-label estimates so edges route around
4654
+ // each other's label areas (Issue #41).
4655
+ ...edgeLabelEstimates
4279
4656
  ];
4280
4657
  const margin = options.obstacleMargin ?? 0;
4281
4658
  const softObstacles = [
@@ -4308,7 +4685,8 @@ function solveDiagram(diagram, options = {}) {
4308
4685
  hardObstacles,
4309
4686
  diagram.direction,
4310
4687
  options,
4311
- diagnostics
4688
+ diagnostics,
4689
+ coordinatedGroups
4312
4690
  );
4313
4691
  const edgeTextAnnotations = coordinateEdgeTextAnnotations(
4314
4692
  coordinatedEdges,
@@ -4421,22 +4799,7 @@ function expandLabelLayoutToNode(layout2, nodeSize) {
4421
4799
  y: layout2.box.y + offsetY,
4422
4800
  width: layout2.box.width,
4423
4801
  height: layout2.box.height
4424
- },
4425
- contentBox: {
4426
- x: layout2.contentBox.x + offsetX,
4427
- y: layout2.contentBox.y + offsetY,
4428
- width: layout2.contentBox.width,
4429
- height: layout2.contentBox.height
4430
- },
4431
- lines: layout2.lines.map((line) => ({
4432
- ...line,
4433
- box: {
4434
- x: line.box.x + offsetX,
4435
- y: line.box.y + offsetY,
4436
- width: line.box.width,
4437
- height: line.box.height
4438
- }
4439
- }))
4802
+ }
4440
4803
  };
4441
4804
  }
4442
4805
  function reportPageOverflow(contentBounds, pageBounds) {
@@ -5383,6 +5746,7 @@ function coordinateNodes(nodes, boxes, options, diagnostics) {
5383
5746
  });
5384
5747
  continue;
5385
5748
  }
5749
+ const ports = node.ports === void 0 ? void 0 : coordinatePorts(node, box, options.portShifting);
5386
5750
  const geometry = computeShapeGeometry({
5387
5751
  shape: node.shape,
5388
5752
  box,
@@ -5392,7 +5756,7 @@ function coordinateNodes(nodes, boxes, options, diagnostics) {
5392
5756
  id: node.id,
5393
5757
  ...node.label === void 0 ? {} : { label: node.label },
5394
5758
  ...node.style === void 0 ? {} : { style: node.style },
5395
- ...node.ports === void 0 ? {} : { ports: coordinatePorts(node, box, options.portShifting) },
5759
+ ...ports === void 0 ? {} : { ports },
5396
5760
  ...node.compartments === void 0 ? {} : { compartments: node.compartments },
5397
5761
  ...node.labelLayout === void 0 ? {} : { labelLayout: node.labelLayout },
5398
5762
  shape: node.shape,
@@ -5404,6 +5768,78 @@ function coordinateNodes(nodes, boxes, options, diagnostics) {
5404
5768
  }
5405
5769
  return coordinated;
5406
5770
  }
5771
+ var PORT_BOX_SIZE = 10;
5772
+ var MIN_PORT_EDGE_GAP = 12;
5773
+ function expandNodeBoxesForPorts(nodes, boxes, options, diagnostics) {
5774
+ const shiftingEnabled = options.portShifting?.enabled ?? true;
5775
+ if (!shiftingEnabled) return;
5776
+ const requestedSpacing = options.portShifting?.spacing ?? 24;
5777
+ const minSpacing = Math.max(
5778
+ requestedSpacing,
5779
+ PORT_BOX_SIZE + MIN_PORT_EDGE_GAP
5780
+ );
5781
+ for (const node of nodes) {
5782
+ if (node.ports === void 0 || node.ports.length === 0) continue;
5783
+ const box = boxes.get(node.id);
5784
+ if (box === void 0) continue;
5785
+ let heightExpansion = 0;
5786
+ let widthExpansion = 0;
5787
+ const portsBySide = /* @__PURE__ */ new Map();
5788
+ for (const port of node.ports) {
5789
+ const list = portsBySide.get(port.side) ?? [];
5790
+ list.push(port);
5791
+ portsBySide.set(port.side, list);
5792
+ }
5793
+ for (const [side, ports] of portsBySide) {
5794
+ const count = (ports ?? []).length;
5795
+ if (count <= 1) continue;
5796
+ const isVertical = side === "left" || side === "right";
5797
+ const availableSpan = isVertical ? box.height : box.width;
5798
+ const requiredSpan = (count - 1) * minSpacing + PORT_BOX_SIZE;
5799
+ if (requiredSpan > availableSpan) {
5800
+ const expansion = requiredSpan - availableSpan;
5801
+ if (isVertical) {
5802
+ heightExpansion = Math.max(heightExpansion, expansion);
5803
+ } else {
5804
+ widthExpansion = Math.max(widthExpansion, expansion);
5805
+ }
5806
+ diagnostics.push({
5807
+ severity: "info",
5808
+ code: "port_capacity_overflow",
5809
+ message: `Expanded node ${node.id} ${isVertical ? "height" : "width"} by ${Math.ceil(expansion)} px to fit ${count} port(s) on ${side} side.`,
5810
+ path: ["nodes", node.id, "ports"],
5811
+ detail: {
5812
+ nodeId: node.id,
5813
+ side,
5814
+ portCount: count,
5815
+ expansion: Math.ceil(expansion)
5816
+ }
5817
+ });
5818
+ }
5819
+ }
5820
+ if (heightExpansion > 0) {
5821
+ box.y -= heightExpansion / 2;
5822
+ box.height += heightExpansion;
5823
+ }
5824
+ if (widthExpansion > 0) {
5825
+ box.x -= widthExpansion / 2;
5826
+ box.width += widthExpansion;
5827
+ }
5828
+ if ((heightExpansion > 0 || widthExpansion > 0) && node.labelLayout !== void 0) {
5829
+ const layout2 = node.labelLayout;
5830
+ const newOffsetX = Math.max(0, (box.width - layout2.box.width) / 2);
5831
+ const newOffsetY = Math.max(0, (box.height - layout2.box.height) / 2);
5832
+ node.labelLayout = {
5833
+ ...layout2,
5834
+ box: {
5835
+ ...layout2.box,
5836
+ x: newOffsetX,
5837
+ y: newOffsetY
5838
+ }
5839
+ };
5840
+ }
5841
+ }
5842
+ }
5407
5843
  function coordinatePorts(node, nodeBox, portShifting) {
5408
5844
  const portsBySide = /* @__PURE__ */ new Map();
5409
5845
  for (const port of node.ports ?? []) {
@@ -5440,7 +5876,11 @@ function portAnchor(nodeBox, side, index, count, portShifting) {
5440
5876
  const requestedSpacing = portShifting?.spacing ?? 24;
5441
5877
  const maxOffset = side === "left" || side === "right" ? nodeBox.height / 2 : nodeBox.width / 2;
5442
5878
  const availableSpan = 2 * maxOffset;
5443
- const spacing = shiftingEnabled && count > 1 ? Math.min(requestedSpacing, availableSpan / (count - 1)) : requestedSpacing;
5879
+ const minSpacing = PORT_BOX_SIZE + MIN_PORT_EDGE_GAP;
5880
+ const spacing = shiftingEnabled && count > 1 ? Math.max(
5881
+ Math.min(requestedSpacing, availableSpan / (count - 1)),
5882
+ minSpacing
5883
+ ) : requestedSpacing;
5444
5884
  const centeredOffset = shiftingEnabled ? (index - (count - 1) / 2) * spacing : 0;
5445
5885
  switch (side) {
5446
5886
  case "left":
@@ -5466,7 +5906,7 @@ function portAnchor(nodeBox, side, index, count, portShifting) {
5466
5906
  }
5467
5907
  }
5468
5908
  function portBox(anchor) {
5469
- const size = 10;
5909
+ const size = PORT_BOX_SIZE;
5470
5910
  return {
5471
5911
  x: anchor.x - size / 2,
5472
5912
  y: anchor.y - size / 2,
@@ -6055,7 +6495,7 @@ function evidenceOverlapDiagnostic(block, conflict) {
6055
6495
  }
6056
6496
  };
6057
6497
  }
6058
- function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacles, textObstacles, hardObstacles, direction, options, diagnostics) {
6498
+ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacles, textObstacles, hardObstacles, direction, options, diagnostics, groups) {
6059
6499
  const coordinated = [];
6060
6500
  const coordinatedNodeById = new Map(
6061
6501
  coordinatedNodes.map((node) => [node.id, node])
@@ -6079,8 +6519,7 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
6079
6519
  }
6080
6520
  const sourcePort = coordinatedNodeById.get(edge.source.nodeId)?.ports?.find((port) => port.id === edge.source.portId);
6081
6521
  const targetPort = coordinatedNodeById.get(edge.target.nodeId)?.ports?.find((port) => port.id === edge.target.portId);
6082
- const connectedTextOwners = edgeConnectedTextOwnerIds(edge);
6083
- const routeTextObstacles = textObstacles.filter((annotation) => !connectedTextOwners.has(annotation.ownerId)).map((annotation) => annotation.box);
6522
+ const routeTextObstacles = textObstacles.filter((annotation) => !isEdgeConnectedTextAnnotation(edge, annotation)).map((annotation) => annotation.box);
6084
6523
  const route = routeEdge({
6085
6524
  kind: options.routeKind ?? "orthogonal",
6086
6525
  direction,
@@ -6093,9 +6532,11 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
6093
6532
  (obstacle) => obstacle !== source.obstacleBox && obstacle !== target.obstacleBox
6094
6533
  ),
6095
6534
  ...softObstacles,
6535
+ ...groupObstaclesForEdge(edge, groups, options.obstacleMargin ?? 0),
6096
6536
  ...routeTextObstacles
6097
6537
  ],
6098
- hardObstacles
6538
+ hardObstacles,
6539
+ ...options.maxRoutingAttempts === void 0 ? {} : { maxRoutingAttempts: options.maxRoutingAttempts }
6099
6540
  });
6100
6541
  diagnostics.push(
6101
6542
  ...route.diagnostics.map((diagnostic) => ({
@@ -6110,15 +6551,52 @@ function coordinateEdges(edges, nodes, coordinatedNodes, obstacles, softObstacle
6110
6551
  }
6111
6552
  return coordinated;
6112
6553
  }
6113
- function edgeConnectedTextOwnerIds(edge) {
6114
- const owners = /* @__PURE__ */ new Set();
6115
- if (edge.source.portId !== void 0) {
6116
- owners.add(`${edge.source.nodeId}.${edge.source.portId}`);
6554
+ function isEdgeConnectedTextAnnotation(edge, annotation) {
6555
+ switch (annotation.surfaceKind) {
6556
+ case "edge-label":
6557
+ return annotation.ownerId === edge.id;
6558
+ case "node-label":
6559
+ case "compartment-row":
6560
+ return annotation.ownerId === edge.source.nodeId || annotation.ownerId === edge.target.nodeId;
6561
+ case "port-label":
6562
+ 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}`;
6563
+ case "group-label":
6564
+ case "swimlane-label":
6565
+ case "frame-title":
6566
+ return false;
6117
6567
  }
6118
- if (edge.target.portId !== void 0) {
6119
- owners.add(`${edge.target.nodeId}.${edge.target.portId}`);
6568
+ }
6569
+ function ancestorGroupIds(groups, nodeId) {
6570
+ const direct = /* @__PURE__ */ new Set();
6571
+ for (const group of groups) {
6572
+ if (group.nodeIds.includes(nodeId)) {
6573
+ direct.add(group.id);
6574
+ }
6575
+ }
6576
+ let previousSize = -1;
6577
+ const ancestors = new Set(direct);
6578
+ while (ancestors.size !== previousSize) {
6579
+ previousSize = ancestors.size;
6580
+ for (const group of groups) {
6581
+ for (const candidate of ancestors) {
6582
+ if (group.groupIds.includes(candidate)) {
6583
+ ancestors.add(group.id);
6584
+ break;
6585
+ }
6586
+ }
6587
+ }
6120
6588
  }
6121
- return owners;
6589
+ return ancestors;
6590
+ }
6591
+ function groupObstaclesForEdge(edge, groups, margin) {
6592
+ const sourceAncestors = ancestorGroupIds(groups, edge.source.nodeId);
6593
+ const targetAncestors = ancestorGroupIds(groups, edge.target.nodeId);
6594
+ return groups.filter((group) => {
6595
+ if (sourceAncestors.has(group.id) || targetAncestors.has(group.id)) {
6596
+ return false;
6597
+ }
6598
+ return true;
6599
+ }).map((group) => margin === 0 ? group.box : expandBox(group.box, margin));
6122
6600
  }
6123
6601
  function coordinateBaseTextAnnotations(input) {
6124
6602
  const measurer = input.textMeasurer ?? createDefaultTextMeasurer();
@@ -6306,6 +6784,55 @@ function coordinateEdgeTextAnnotations(edges, obstacleBoxes, textMeasurer, label
6306
6784
  }
6307
6785
  return annotations;
6308
6786
  }
6787
+ function estimateEdgeLabelAnnotations(edges, nodes, textMeasurer) {
6788
+ const measurer = textMeasurer ?? createDefaultTextMeasurer();
6789
+ const annotations = [];
6790
+ for (const edge of edges) {
6791
+ if (edge.label?.text === void 0) {
6792
+ continue;
6793
+ }
6794
+ const sourceGeom = nodes.get(edge.source.nodeId);
6795
+ const targetGeom = nodes.get(edge.target.nodeId);
6796
+ if (sourceGeom === void 0 || targetGeom === void 0) {
6797
+ continue;
6798
+ }
6799
+ const layout2 = fitLabel(
6800
+ edge.label.text,
6801
+ {
6802
+ font: typographyTextStyle(edge.label, {
6803
+ fontFamily: "Arial",
6804
+ fontSize: 12,
6805
+ lineHeight: 14
6806
+ }),
6807
+ padding: { top: 0, right: 0, bottom: 0, left: 0 },
6808
+ minSize: { width: 0, height: 0 },
6809
+ maxWidth: 200
6810
+ },
6811
+ measurer
6812
+ );
6813
+ const cx = (sourceGeom.center.x + targetGeom.center.x) / 2;
6814
+ const cy = (sourceGeom.center.y + targetGeom.center.y) / 2;
6815
+ const box = {
6816
+ x: cx - layout2.box.width / 2,
6817
+ y: cy - layout2.box.height / 2,
6818
+ width: layout2.box.width,
6819
+ height: layout2.box.height
6820
+ };
6821
+ annotations.push({
6822
+ text: layout2.text,
6823
+ ownerId: edge.id,
6824
+ surfaceKind: "edge-label",
6825
+ box,
6826
+ anchor: { x: cx, y: cy },
6827
+ paddings: layout2.padding,
6828
+ lines: layout2.lines,
6829
+ fontFamily: normalizeOutputFontFamily(layout2.font),
6830
+ fontSize: layout2.font.fontSize,
6831
+ textBackend: layout2.textBackend
6832
+ });
6833
+ }
6834
+ return annotations;
6835
+ }
6309
6836
  function coordinateFrameTextAnnotation(frame, textMeasurer) {
6310
6837
  const layout2 = fitLabel(
6311
6838
  frame.titleTab,
@@ -6426,9 +6953,8 @@ function reportRouteTextClearance(edges, annotations) {
6426
6953
  const diagnostics = [];
6427
6954
  const relevantAnnotations = annotations.filter(isRouteClearanceText);
6428
6955
  for (const edge of edges) {
6429
- const connectedTextOwners = edgeConnectedTextOwnerIds(edge);
6430
6956
  for (const annotation of relevantAnnotations) {
6431
- if (annotation.ownerId === edge.id || connectedTextOwners.has(annotation.ownerId)) {
6957
+ if (isEdgeConnectedTextAnnotation(edge, annotation)) {
6432
6958
  continue;
6433
6959
  }
6434
6960
  if (!routeIntersectsTextBox(edge.points, annotation.box)) {
@@ -6452,9 +6978,6 @@ function reportRouteTextClearance(edges, annotations) {
6452
6978
  return diagnostics;
6453
6979
  }
6454
6980
  function isPreRouteTextObstacle(annotation) {
6455
- if (annotation.surfaceKind === "edge-label") {
6456
- return false;
6457
- }
6458
6981
  return isRouteClearanceText(annotation);
6459
6982
  }
6460
6983
  function isRouteClearanceText(annotation) {
@@ -6465,8 +6988,9 @@ function isRouteClearanceText(annotation) {
6465
6988
  case "frame-title":
6466
6989
  return true;
6467
6990
  case "node-label":
6468
- case "group-label":
6469
6991
  case "compartment-row":
6992
+ return true;
6993
+ case "group-label":
6470
6994
  return textExtendsOutsideAnchor(annotation);
6471
6995
  }
6472
6996
  }
@@ -6499,17 +7023,17 @@ function segmentIntersectsBox2(start, end, box) {
6499
7023
  return true;
6500
7024
  }
6501
7025
  if (start.x === end.x) {
6502
- return start.x > left && start.x < right && rangesOverlap2(start.y, end.y, top, bottom);
7026
+ return start.x > left && start.x < right && rangesOverlap3(start.y, end.y, top, bottom);
6503
7027
  }
6504
7028
  if (start.y === end.y) {
6505
- return start.y > top && start.y < bottom && rangesOverlap2(start.x, end.x, left, right);
7029
+ return start.y > top && start.y < bottom && rangesOverlap3(start.x, end.x, left, right);
6506
7030
  }
6507
7031
  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);
6508
7032
  }
6509
7033
  function pointInsideBox2(point2, box) {
6510
7034
  return point2.x > box.x && point2.x < box.x + box.width && point2.y > box.y && point2.y < box.y + box.height;
6511
7035
  }
6512
- function rangesOverlap2(a, b, min, max) {
7036
+ function rangesOverlap3(a, b, min, max) {
6513
7037
  const low = Math.min(a, b);
6514
7038
  const high = Math.max(a, b);
6515
7039
  return high > min && low < max;
@@ -6578,7 +7102,8 @@ function edgeLabelAnchor(edge, layout2, edges, obstacleBoxes, placedLabelBoxes,
6578
7102
  for (const candidate of edgeLabelAnchorCandidates(
6579
7103
  edge.points,
6580
7104
  placement,
6581
- layout2
7105
+ layout2,
7106
+ baseOffset
6582
7107
  )) {
6583
7108
  const labelBox = {
6584
7109
  x: candidate.x - layout2.box.width / 2,
@@ -6610,8 +7135,8 @@ function edgeLabelAnchor(edge, layout2, edges, obstacleBoxes, placedLabelBoxes,
6610
7135
  }
6611
7136
  return placement;
6612
7137
  }
6613
- function edgeLabelAnchorCandidates(points, placement, layout2) {
6614
- const segment = labelSegmentOnPolyline(points);
7138
+ function edgeLabelAnchorCandidates(points, placement, layout2, baseOffset = 10) {
7139
+ const segment = labelSegmentOnPolyline(points, baseOffset);
6615
7140
  if (segment === void 0) {
6616
7141
  return [placement];
6617
7142
  }
@@ -6661,7 +7186,7 @@ function edgeLabelAnchorCandidates(points, placement, layout2) {
6661
7186
  }, 0);
6662
7187
  if (totalLen > 200) {
6663
7188
  for (const ratio of [0.25, 0.75]) {
6664
- const qp = labelPlacementAtRatio(points, ratio, totalLen);
7189
+ const qp = labelPlacementAtRatio(points, ratio, totalLen, baseOffset);
6665
7190
  if (qp !== void 0) {
6666
7191
  candidates.push(qp);
6667
7192
  const qTargetDist = totalLen * ratio;
@@ -6747,7 +7272,7 @@ function labelSegmentOnPolyline(points, baseOffset = 10) {
6747
7272
  if (last === void 0) {
6748
7273
  return void 0;
6749
7274
  }
6750
- const offset = labelOffset2(last);
7275
+ const offset = labelOffset2(last, baseOffset);
6751
7276
  return {
6752
7277
  start: last.start,
6753
7278
  end: last.end,
@@ -6769,7 +7294,7 @@ function nonZeroSegments2(points) {
6769
7294
  }
6770
7295
  return segments;
6771
7296
  }
6772
- function labelPlacementAtRatio(points, ratio, totalLength) {
7297
+ function labelPlacementAtRatio(points, ratio, totalLength, baseOffset = 10) {
6773
7298
  if (points.length < 2 || ratio < 0 || ratio > 1) {
6774
7299
  return void 0;
6775
7300
  }
@@ -6787,7 +7312,10 @@ function labelPlacementAtRatio(points, ratio, totalLength) {
6787
7312
  }
6788
7313
  if (travelled + segLen >= targetDist) {
6789
7314
  const t = (targetDist - travelled) / segLen;
6790
- const offset = labelOffset2({ start: prev, end: curr, length: segLen });
7315
+ const offset = labelOffset2(
7316
+ { start: prev, end: curr, length: segLen },
7317
+ baseOffset
7318
+ );
6791
7319
  return {
6792
7320
  x: prev.x + (curr.x - prev.x) * t + offset.x,
6793
7321
  y: prev.y + (curr.y - prev.y) * t + offset.y