@skygraph/core 0.4.0 → 0.5.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/graph.d.cts CHANGED
@@ -402,7 +402,8 @@ interface RouteOrthogonalOptions {
402
402
  targetBounds?: AABB;
403
403
  /**
404
404
  * Length (world units) of the perpendicular stub generated when
405
- * `sourceBounds` / `targetBounds` is set. Defaults to `max(8, gridSize)`.
405
+ * `sourceBounds` / `targetBounds` is set. Defaults to `max(20, gridSize)`
406
+ * — matches React Flow's `offset = 20` for smoothstep edges.
406
407
  */
407
408
  stubLength?: number;
408
409
  /**
@@ -412,6 +413,19 @@ interface RouteOrthogonalOptions {
412
413
  * affects the L-route fallback.
413
414
  */
414
415
  snap?: number;
416
+ /**
417
+ * Where the central bend sits on the connector when both
418
+ * `sourceBounds` and `targetBounds` are provided.
419
+ *
420
+ * - `0` → bend right at the source stub
421
+ * - `0.5` → midpoint between the two stubs (default — matches
422
+ * React Flow's `stepPosition`)
423
+ * - `1` → bend right at the target stub
424
+ *
425
+ * Ignored when bounds are missing (we fall back to the legacy L-route)
426
+ * or when A* is engaged (it picks bends from path-cost, not position).
427
+ */
428
+ stepPosition?: number;
415
429
  }
416
430
  /**
417
431
  * Compute an orthogonal (right-angled) path between two points.
@@ -483,6 +497,23 @@ interface BezierPathOptions {
483
497
  * point when the caller doesn't already know it.
484
498
  */
485
499
  declare function getBezierPath(opts: BezierPathOptions): string;
500
+ /**
501
+ * Compute the intersection of the line from `box`'s centre to
502
+ * `opposite` with the rectangle's border. Returns the border point and
503
+ * the side it sits on.
504
+ *
505
+ * This is the "floating anchor" trick used by React Flow's
506
+ * FloatingEdges example and most graph viz libraries: instead of
507
+ * binding an endpoint to a fixed corner or midpoint, you bind it to
508
+ * the centre and let it slide along the perimeter following the
509
+ * direction to the other node. The result is dramatically cleaner on
510
+ * dense / unstructured graphs because every edge picks the closest
511
+ * face automatically as nodes are dragged around.
512
+ */
513
+ declare function floatingAnchor(box: AABB, opposite: Point): {
514
+ point: Point;
515
+ side: Side;
516
+ };
486
517
  /**
487
518
  * Pick the side of `box` whose midpoint is closest to `target`. Same
488
519
  * logic as the internal helper used by orthogonal routing — exposed
@@ -490,6 +521,104 @@ declare function getBezierPath(opts: BezierPathOptions): string;
490
521
  * it.
491
522
  */
492
523
  declare function nearestSide(box: AABB, target: Point): Side;
524
+ /**
525
+ * Resolved edge endpoint geometry — output of {@link resolveEdgeEndpoint}.
526
+ *
527
+ * `point` is in world space, sitting on (or just outside of) the
528
+ * `box` border. `side` records which face the connector exits / enters
529
+ * so downstream consumers (bezier control handles, orthogonal stubs)
530
+ * can stay perpendicular.
531
+ */
532
+ interface ResolvedEndpoint {
533
+ point: Point;
534
+ side: Side;
535
+ }
536
+ /**
537
+ * Compute the intersection of the segment between the centre of
538
+ * `intersectionBox` and `targetCenter` with the perimeter of
539
+ * `intersectionBox`.
540
+ *
541
+ * **Source / credit.** Direct port of the algorithm used by xyflow
542
+ * (React Flow) for FloatingEdges and by mxGraph / draw.io's
543
+ * `mxPerimeter.RectanglePerimeter`. The closed-form derivation is in
544
+ * https://math.stackexchange.com/questions/1724792 — it parametrises
545
+ * the rectangle as the manhattan-norm rhombus `|x| + |y| = 1` and
546
+ * solves for the unit intersection in one step, which is faster and
547
+ * numerically more stable than the angle-based formulation.
548
+ *
549
+ * We use this as the canonical endpoint resolver for every routing
550
+ * mode (`straight` / `orthogonal` / `bezier`). The visible benefit is
551
+ * stability: nothing in this function depends on user-supplied
552
+ * anchors — the side, the point, the angle of approach are all pure
553
+ * functions of the two rectangles' bounding boxes, so dragging a node
554
+ * around never produces the "wrong-side-of-the-box" / "edge cuts
555
+ * through the node body" artifacts that the anchor-honouring resolver
556
+ * used to leak.
557
+ *
558
+ * Returns the world-space intersection point. When `box` has zero
559
+ * area (degenerate node) the function returns its centre so callers
560
+ * can still draw a useful edge to it.
561
+ */
562
+ declare function getNodeIntersection(intersectionBox: AABB, targetCenter: Point): Point;
563
+ /**
564
+ * Classify which face of `box` the `intersectionPoint` sits on.
565
+ * Companion to {@link getNodeIntersection} — together they replace the
566
+ * old anchor-driven endpoint resolver.
567
+ *
568
+ * The function rounds both the box position and the point to integer
569
+ * pixels (matches the xyflow heuristic) before comparing, which keeps
570
+ * floating-point noise from picking the wrong side when an
571
+ * intersection lands almost exactly on a corner.
572
+ */
573
+ declare function getEdgePosition(box: AABB, intersectionPoint: Point): Side;
574
+ /**
575
+ * Single canonical edge endpoint resolver used by `<Diagram>` for
576
+ * every routing mode. Wraps {@link getNodeIntersection} +
577
+ * {@link getEdgePosition} into the `{ point, side }` shape the
578
+ * adapters expect, and applies a small outward `padding` so the
579
+ * arrowhead marker has room to sit between the path tip and the node
580
+ * border (matches React Flow / draw.io behaviour).
581
+ *
582
+ * **Important: anchor information is intentionally ignored here.**
583
+ * The `<Diagram>` API still accepts `from: { anchor: 'nw' }` etc. for
584
+ * back-compat and semantic intent, but the visual layer now derives
585
+ * the endpoint purely from bounding boxes. This is the floating-edges
586
+ * approach taken by every mainstream diagram library — it's what
587
+ * makes drag-around-the-canvas behaviour feel correct on randomly
588
+ * laid-out graphs.
589
+ */
590
+ declare function resolveEdgeEndpoint(sourceBox: AABB, targetBox: AABB, padding?: number): ResolvedEndpoint;
591
+ /**
592
+ * Decide which face an endpoint sits on, combining two cues:
593
+ *
594
+ * 1. **Anchor position** — if `anchor` is already at one of `box`'s
595
+ * borders (within `tolerance`), the corresponding face is returned
596
+ * as-is. This lets consumers express intent ("exit from the right
597
+ * side") simply by placing the anchor on that border.
598
+ *
599
+ * 2. **Geometry fallback** — when the anchor is interior to `box`
600
+ * (or no border match) we fall back to {@link nearestSide} pointed
601
+ * at the opposite endpoint. This is the right answer for centre
602
+ * anchors and produces stable sides for big graphs where every
603
+ * pair of nodes has a different relative position.
604
+ *
605
+ * Returns the side along with a `confident` flag — `true` when the
606
+ * decision came from the anchor position (case 1), `false` when it
607
+ * fell back to geometry (case 2). Consumers that want to override the
608
+ * fallback in special cases can branch on the flag.
609
+ *
610
+ * Corner anchors (touching two borders simultaneously like `nw` / `ne`)
611
+ * are disambiguated by which face points more strongly at the opposite
612
+ * endpoint. **The disambiguation is also clamped to faces that face the
613
+ * other node** — a `ne` anchor on the source whose target sits to its
614
+ * left will pick `left` instead of `right`, because using `right` would
615
+ * route the edge AWAY from the target. This is the key fix for the
616
+ * "stub-then-180°-loop" artifact visible on randomly-laid-out graphs.
617
+ */
618
+ declare function inferSide(box: AABB, anchor: Point, opposite: Point, tolerance?: number): {
619
+ side: Side;
620
+ confident: boolean;
621
+ };
493
622
 
494
623
  /**
495
624
  * Oriented Bounding Box — a rectangle in world space described by its centre,
@@ -539,4 +668,4 @@ declare function aabbFromOBB(obb: OBB): AABB;
539
668
  */
540
669
  declare function obbContainsPoint(obb: OBB, p: Point): boolean;
541
670
 
542
- export { type AABB, type Anchor, type AnchorId, type AnchorPolicy, type BezierPathOptions, type EdgeEndpoint, type EdgeId, type EdgeInit, type GraphEdge, type GraphEngine, type GraphEngineOptions, type GraphNode, type GraphState, type NodeId, type NodeInit, type NodeTransform, type NodeUpdate, type OBB$1 as OBB, type Outline, type Point, type RouteOrthogonalOptions, type Side, aabbFromOBB, createGraph, getBezierPath, nearestSide, obbContainsPoint, obbCorners, pointsToPath, pointsToRoundedPath, routeOrthogonal };
671
+ export { type AABB, type Anchor, type AnchorId, type AnchorPolicy, type BezierPathOptions, type EdgeEndpoint, type EdgeId, type EdgeInit, type GraphEdge, type GraphEngine, type GraphEngineOptions, type GraphNode, type GraphState, type NodeId, type NodeInit, type NodeTransform, type NodeUpdate, type OBB$1 as OBB, type Outline, type Point, type ResolvedEndpoint, type RouteOrthogonalOptions, type Side, aabbFromOBB, createGraph, floatingAnchor, getBezierPath, getEdgePosition, getNodeIntersection, inferSide, nearestSide, obbContainsPoint, obbCorners, pointsToPath, pointsToRoundedPath, resolveEdgeEndpoint, routeOrthogonal };
package/dist/graph.d.ts CHANGED
@@ -402,7 +402,8 @@ interface RouteOrthogonalOptions {
402
402
  targetBounds?: AABB;
403
403
  /**
404
404
  * Length (world units) of the perpendicular stub generated when
405
- * `sourceBounds` / `targetBounds` is set. Defaults to `max(8, gridSize)`.
405
+ * `sourceBounds` / `targetBounds` is set. Defaults to `max(20, gridSize)`
406
+ * — matches React Flow's `offset = 20` for smoothstep edges.
406
407
  */
407
408
  stubLength?: number;
408
409
  /**
@@ -412,6 +413,19 @@ interface RouteOrthogonalOptions {
412
413
  * affects the L-route fallback.
413
414
  */
414
415
  snap?: number;
416
+ /**
417
+ * Where the central bend sits on the connector when both
418
+ * `sourceBounds` and `targetBounds` are provided.
419
+ *
420
+ * - `0` → bend right at the source stub
421
+ * - `0.5` → midpoint between the two stubs (default — matches
422
+ * React Flow's `stepPosition`)
423
+ * - `1` → bend right at the target stub
424
+ *
425
+ * Ignored when bounds are missing (we fall back to the legacy L-route)
426
+ * or when A* is engaged (it picks bends from path-cost, not position).
427
+ */
428
+ stepPosition?: number;
415
429
  }
416
430
  /**
417
431
  * Compute an orthogonal (right-angled) path between two points.
@@ -483,6 +497,23 @@ interface BezierPathOptions {
483
497
  * point when the caller doesn't already know it.
484
498
  */
485
499
  declare function getBezierPath(opts: BezierPathOptions): string;
500
+ /**
501
+ * Compute the intersection of the line from `box`'s centre to
502
+ * `opposite` with the rectangle's border. Returns the border point and
503
+ * the side it sits on.
504
+ *
505
+ * This is the "floating anchor" trick used by React Flow's
506
+ * FloatingEdges example and most graph viz libraries: instead of
507
+ * binding an endpoint to a fixed corner or midpoint, you bind it to
508
+ * the centre and let it slide along the perimeter following the
509
+ * direction to the other node. The result is dramatically cleaner on
510
+ * dense / unstructured graphs because every edge picks the closest
511
+ * face automatically as nodes are dragged around.
512
+ */
513
+ declare function floatingAnchor(box: AABB, opposite: Point): {
514
+ point: Point;
515
+ side: Side;
516
+ };
486
517
  /**
487
518
  * Pick the side of `box` whose midpoint is closest to `target`. Same
488
519
  * logic as the internal helper used by orthogonal routing — exposed
@@ -490,6 +521,104 @@ declare function getBezierPath(opts: BezierPathOptions): string;
490
521
  * it.
491
522
  */
492
523
  declare function nearestSide(box: AABB, target: Point): Side;
524
+ /**
525
+ * Resolved edge endpoint geometry — output of {@link resolveEdgeEndpoint}.
526
+ *
527
+ * `point` is in world space, sitting on (or just outside of) the
528
+ * `box` border. `side` records which face the connector exits / enters
529
+ * so downstream consumers (bezier control handles, orthogonal stubs)
530
+ * can stay perpendicular.
531
+ */
532
+ interface ResolvedEndpoint {
533
+ point: Point;
534
+ side: Side;
535
+ }
536
+ /**
537
+ * Compute the intersection of the segment between the centre of
538
+ * `intersectionBox` and `targetCenter` with the perimeter of
539
+ * `intersectionBox`.
540
+ *
541
+ * **Source / credit.** Direct port of the algorithm used by xyflow
542
+ * (React Flow) for FloatingEdges and by mxGraph / draw.io's
543
+ * `mxPerimeter.RectanglePerimeter`. The closed-form derivation is in
544
+ * https://math.stackexchange.com/questions/1724792 — it parametrises
545
+ * the rectangle as the manhattan-norm rhombus `|x| + |y| = 1` and
546
+ * solves for the unit intersection in one step, which is faster and
547
+ * numerically more stable than the angle-based formulation.
548
+ *
549
+ * We use this as the canonical endpoint resolver for every routing
550
+ * mode (`straight` / `orthogonal` / `bezier`). The visible benefit is
551
+ * stability: nothing in this function depends on user-supplied
552
+ * anchors — the side, the point, the angle of approach are all pure
553
+ * functions of the two rectangles' bounding boxes, so dragging a node
554
+ * around never produces the "wrong-side-of-the-box" / "edge cuts
555
+ * through the node body" artifacts that the anchor-honouring resolver
556
+ * used to leak.
557
+ *
558
+ * Returns the world-space intersection point. When `box` has zero
559
+ * area (degenerate node) the function returns its centre so callers
560
+ * can still draw a useful edge to it.
561
+ */
562
+ declare function getNodeIntersection(intersectionBox: AABB, targetCenter: Point): Point;
563
+ /**
564
+ * Classify which face of `box` the `intersectionPoint` sits on.
565
+ * Companion to {@link getNodeIntersection} — together they replace the
566
+ * old anchor-driven endpoint resolver.
567
+ *
568
+ * The function rounds both the box position and the point to integer
569
+ * pixels (matches the xyflow heuristic) before comparing, which keeps
570
+ * floating-point noise from picking the wrong side when an
571
+ * intersection lands almost exactly on a corner.
572
+ */
573
+ declare function getEdgePosition(box: AABB, intersectionPoint: Point): Side;
574
+ /**
575
+ * Single canonical edge endpoint resolver used by `<Diagram>` for
576
+ * every routing mode. Wraps {@link getNodeIntersection} +
577
+ * {@link getEdgePosition} into the `{ point, side }` shape the
578
+ * adapters expect, and applies a small outward `padding` so the
579
+ * arrowhead marker has room to sit between the path tip and the node
580
+ * border (matches React Flow / draw.io behaviour).
581
+ *
582
+ * **Important: anchor information is intentionally ignored here.**
583
+ * The `<Diagram>` API still accepts `from: { anchor: 'nw' }` etc. for
584
+ * back-compat and semantic intent, but the visual layer now derives
585
+ * the endpoint purely from bounding boxes. This is the floating-edges
586
+ * approach taken by every mainstream diagram library — it's what
587
+ * makes drag-around-the-canvas behaviour feel correct on randomly
588
+ * laid-out graphs.
589
+ */
590
+ declare function resolveEdgeEndpoint(sourceBox: AABB, targetBox: AABB, padding?: number): ResolvedEndpoint;
591
+ /**
592
+ * Decide which face an endpoint sits on, combining two cues:
593
+ *
594
+ * 1. **Anchor position** — if `anchor` is already at one of `box`'s
595
+ * borders (within `tolerance`), the corresponding face is returned
596
+ * as-is. This lets consumers express intent ("exit from the right
597
+ * side") simply by placing the anchor on that border.
598
+ *
599
+ * 2. **Geometry fallback** — when the anchor is interior to `box`
600
+ * (or no border match) we fall back to {@link nearestSide} pointed
601
+ * at the opposite endpoint. This is the right answer for centre
602
+ * anchors and produces stable sides for big graphs where every
603
+ * pair of nodes has a different relative position.
604
+ *
605
+ * Returns the side along with a `confident` flag — `true` when the
606
+ * decision came from the anchor position (case 1), `false` when it
607
+ * fell back to geometry (case 2). Consumers that want to override the
608
+ * fallback in special cases can branch on the flag.
609
+ *
610
+ * Corner anchors (touching two borders simultaneously like `nw` / `ne`)
611
+ * are disambiguated by which face points more strongly at the opposite
612
+ * endpoint. **The disambiguation is also clamped to faces that face the
613
+ * other node** — a `ne` anchor on the source whose target sits to its
614
+ * left will pick `left` instead of `right`, because using `right` would
615
+ * route the edge AWAY from the target. This is the key fix for the
616
+ * "stub-then-180°-loop" artifact visible on randomly-laid-out graphs.
617
+ */
618
+ declare function inferSide(box: AABB, anchor: Point, opposite: Point, tolerance?: number): {
619
+ side: Side;
620
+ confident: boolean;
621
+ };
493
622
 
494
623
  /**
495
624
  * Oriented Bounding Box — a rectangle in world space described by its centre,
@@ -539,4 +668,4 @@ declare function aabbFromOBB(obb: OBB): AABB;
539
668
  */
540
669
  declare function obbContainsPoint(obb: OBB, p: Point): boolean;
541
670
 
542
- export { type AABB, type Anchor, type AnchorId, type AnchorPolicy, type BezierPathOptions, type EdgeEndpoint, type EdgeId, type EdgeInit, type GraphEdge, type GraphEngine, type GraphEngineOptions, type GraphNode, type GraphState, type NodeId, type NodeInit, type NodeTransform, type NodeUpdate, type OBB$1 as OBB, type Outline, type Point, type RouteOrthogonalOptions, type Side, aabbFromOBB, createGraph, getBezierPath, nearestSide, obbContainsPoint, obbCorners, pointsToPath, pointsToRoundedPath, routeOrthogonal };
671
+ export { type AABB, type Anchor, type AnchorId, type AnchorPolicy, type BezierPathOptions, type EdgeEndpoint, type EdgeId, type EdgeInit, type GraphEdge, type GraphEngine, type GraphEngineOptions, type GraphNode, type GraphState, type NodeId, type NodeInit, type NodeTransform, type NodeUpdate, type OBB$1 as OBB, type Outline, type Point, type ResolvedEndpoint, type RouteOrthogonalOptions, type Side, aabbFromOBB, createGraph, floatingAnchor, getBezierPath, getEdgePosition, getNodeIntersection, inferSide, nearestSide, obbContainsPoint, obbCorners, pointsToPath, pointsToRoundedPath, resolveEdgeEndpoint, routeOrthogonal };
package/dist/graph.js CHANGED
@@ -1,24 +1,34 @@
1
1
  import {
2
2
  aabbFromOBB,
3
3
  createGraph,
4
+ floatingAnchor,
4
5
  getBezierPath,
6
+ getEdgePosition,
7
+ getNodeIntersection,
8
+ inferSide,
5
9
  nearestSide,
6
10
  obbContainsPoint,
7
11
  obbCorners,
8
12
  pointsToPath,
9
13
  pointsToRoundedPath,
14
+ resolveEdgeEndpoint,
10
15
  routeOrthogonal
11
- } from "./chunk-KP75DEA4.js";
16
+ } from "./chunk-7FA2TBSZ.js";
12
17
  import "./chunk-5CCD5Q4B.js";
13
18
  export {
14
19
  aabbFromOBB,
15
20
  createGraph,
21
+ floatingAnchor,
16
22
  getBezierPath,
23
+ getEdgePosition,
24
+ getNodeIntersection,
25
+ inferSide,
17
26
  nearestSide,
18
27
  obbContainsPoint,
19
28
  obbCorners,
20
29
  pointsToPath,
21
30
  pointsToRoundedPath,
31
+ resolveEdgeEndpoint,
22
32
  routeOrthogonal
23
33
  };
24
34
  //# sourceMappingURL=graph.js.map
package/dist/index.cjs CHANGED
@@ -38,8 +38,12 @@ __export(src_exports, {
38
38
  createTypedCore: () => createTypedCore,
39
39
  createVirtual: () => createVirtual,
40
40
  detectConflicts: () => detectConflicts,
41
+ floatingAnchor: () => floatingAnchor,
41
42
  freezeMiddleware: () => freezeMiddleware,
42
43
  getBezierPath: () => getBezierPath,
44
+ getEdgePosition: () => getEdgePosition,
45
+ getNodeIntersection: () => getNodeIntersection,
46
+ inferSide: () => inferSide,
43
47
  isAvailable: () => isAvailable,
44
48
  listReservedPrefixes: () => listReservedPrefixes,
45
49
  loggerMiddleware: () => loggerMiddleware,
@@ -52,6 +56,7 @@ __export(src_exports, {
52
56
  pointsToPath: () => pointsToPath,
53
57
  pointsToRoundedPath: () => pointsToRoundedPath,
54
58
  reservePrefix: () => reservePrefix,
59
+ resolveEdgeEndpoint: () => resolveEdgeEndpoint,
55
60
  resolveOperator: () => resolveOperator,
56
61
  routeOrthogonal: () => routeOrthogonal,
57
62
  validationMiddleware: () => validationMiddleware
@@ -2013,22 +2018,27 @@ function createGraph(core, options) {
2013
2018
  function routeOrthogonal(start, end, options) {
2014
2019
  const opts = typeof options === "string" ? { preferred: options } : options ?? {};
2015
2020
  const gridSize = opts.gridSize ?? 10;
2016
- const stubLength = opts.stubLength ?? Math.max(8, gridSize);
2021
+ const stubLength = opts.stubLength ?? Math.max(20, gridSize);
2022
+ const stepPosition = opts.stepPosition ?? 0.5;
2017
2023
  let sExit = null;
2018
2024
  let sStub = null;
2019
2025
  let sCorner = null;
2026
+ let sSide = null;
2020
2027
  if (opts.sourceBounds) {
2021
- const exitInfo = exitOnNearestSide(opts.sourceBounds, end);
2028
+ const exitInfo = exitOnNearestSide(opts.sourceBounds, end, start);
2022
2029
  sExit = exitInfo.point;
2030
+ sSide = exitInfo.side;
2023
2031
  sStub = extrudePoint(exitInfo.point, exitInfo.side, stubLength);
2024
2032
  sCorner = orthoBridge(start, sExit, exitInfo.side);
2025
2033
  }
2026
2034
  let tExit = null;
2027
2035
  let tStub = null;
2028
2036
  let tCorner = null;
2037
+ let tSide = null;
2029
2038
  if (opts.targetBounds) {
2030
- const enterInfo = exitOnNearestSide(opts.targetBounds, start);
2039
+ const enterInfo = exitOnNearestSide(opts.targetBounds, start, end);
2031
2040
  tExit = enterInfo.point;
2041
+ tSide = enterInfo.side;
2032
2042
  tStub = extrudePoint(enterInfo.point, enterInfo.side, stubLength);
2033
2043
  tCorner = orthoBridge(end, tExit, enterInfo.side);
2034
2044
  }
@@ -2038,8 +2048,12 @@ function routeOrthogonal(start, end, options) {
2038
2048
  const filteredObstacles = obstacles.length > 0 && (opts.sourceBounds || opts.targetBounds) ? obstacles.filter((o) => o !== opts.sourceBounds && o !== opts.targetBounds) : obstacles;
2039
2049
  let core;
2040
2050
  if (filteredObstacles.length === 0) {
2041
- const snap2 = opts.snap ?? gridSize;
2042
- core = lRoute(coreStart, coreEnd, opts.preferred ?? "auto", snap2);
2051
+ if (sSide && tSide) {
2052
+ core = smoothStepRoute(coreStart, sSide, coreEnd, tSide, stepPosition);
2053
+ } else {
2054
+ const snap2 = opts.snap ?? gridSize;
2055
+ core = lRoute(coreStart, coreEnd, opts.preferred ?? "auto", snap2);
2056
+ }
2043
2057
  } else {
2044
2058
  const inflate = opts.inflate ?? 0;
2045
2059
  const maxNodes = opts.maxNodes ?? 5e3;
@@ -2063,6 +2077,68 @@ function routeOrthogonal(start, end, options) {
2063
2077
  if (!pointsEqual(end, points[points.length - 1])) pushIfDistinct(points, end);
2064
2078
  return compressCollinear(points);
2065
2079
  }
2080
+ function smoothStepRoute(coreStart, sSide, coreEnd, tSide, stepPosition) {
2081
+ const [sx, sy] = coreStart;
2082
+ const [tx, ty] = coreEnd;
2083
+ const sAxis = sideAxis(sSide);
2084
+ const tAxis = sideAxis(tSide);
2085
+ const sDir = sideDir(sSide);
2086
+ const tDir = sideDir(tSide);
2087
+ if (sAxis === tAxis) {
2088
+ if (sDir + tDir === 0) {
2089
+ if (sAxis === "x") {
2090
+ const cx = sx + (tx - sx) * stepPosition;
2091
+ return [
2092
+ [sx, sy],
2093
+ [cx, sy],
2094
+ [cx, ty],
2095
+ [tx, ty]
2096
+ ];
2097
+ }
2098
+ const cy = sy + (ty - sy) * stepPosition;
2099
+ return [
2100
+ [sx, sy],
2101
+ [sx, cy],
2102
+ [tx, cy],
2103
+ [tx, ty]
2104
+ ];
2105
+ }
2106
+ if (sAxis === "x") {
2107
+ const extX = sDir > 0 ? Math.max(sx, tx) : Math.min(sx, tx);
2108
+ return [
2109
+ [sx, sy],
2110
+ [extX, sy],
2111
+ [extX, ty],
2112
+ [tx, ty]
2113
+ ];
2114
+ }
2115
+ const extY = sDir > 0 ? Math.max(sy, ty) : Math.min(sy, ty);
2116
+ return [
2117
+ [sx, sy],
2118
+ [sx, extY],
2119
+ [tx, extY],
2120
+ [tx, ty]
2121
+ ];
2122
+ }
2123
+ if (sAxis === "x") {
2124
+ return [
2125
+ [sx, sy],
2126
+ [tx, sy],
2127
+ [tx, ty]
2128
+ ];
2129
+ }
2130
+ return [
2131
+ [sx, sy],
2132
+ [sx, ty],
2133
+ [tx, ty]
2134
+ ];
2135
+ }
2136
+ function sideAxis(s) {
2137
+ return s === "left" || s === "right" ? "x" : "y";
2138
+ }
2139
+ function sideDir(s) {
2140
+ return s === "right" || s === "bottom" ? 1 : -1;
2141
+ }
2066
2142
  function pointsToPath(points) {
2067
2143
  if (points.length === 0) return "";
2068
2144
  const [head, ...rest] = points;
@@ -2122,6 +2198,31 @@ function getBezierPath(opts) {
2122
2198
  const [c2x, c2y] = controlPoint(opts.targetSide, tx, ty, sx, sy, curvature);
2123
2199
  return `M ${sx} ${sy} C ${c1x} ${c1y} ${c2x} ${c2y} ${tx} ${ty}`;
2124
2200
  }
2201
+ function floatingAnchor(box, opposite) {
2202
+ const cx = box.x + box.w / 2;
2203
+ const cy = box.y + box.h / 2;
2204
+ const dx = opposite[0] - cx;
2205
+ const dy = opposite[1] - cy;
2206
+ if (dx === 0 && dy === 0) {
2207
+ return { point: [box.x + box.w, cy], side: "right" };
2208
+ }
2209
+ const hw = box.w / 2;
2210
+ const hh = box.h / 2;
2211
+ const tx = dx === 0 ? Infinity : hw / Math.abs(dx);
2212
+ const ty = dy === 0 ? Infinity : hh / Math.abs(dy);
2213
+ if (tx < ty) {
2214
+ const sign2 = dx > 0 ? 1 : -1;
2215
+ return {
2216
+ point: [cx + sign2 * hw, cy + sign2 * (hw / Math.abs(dx)) * dy],
2217
+ side: sign2 > 0 ? "right" : "left"
2218
+ };
2219
+ }
2220
+ const sign = dy > 0 ? 1 : -1;
2221
+ return {
2222
+ point: [cx + sign * (hh / Math.abs(dy)) * dx, cy + sign * hh],
2223
+ side: sign > 0 ? "bottom" : "top"
2224
+ };
2225
+ }
2125
2226
  function nearestSide(box, target) {
2126
2227
  const cx = box.x + box.w / 2;
2127
2228
  const cy = box.y + box.h / 2;
@@ -2130,9 +2231,107 @@ function nearestSide(box, target) {
2130
2231
  if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? "right" : "left";
2131
2232
  return dy >= 0 ? "bottom" : "top";
2132
2233
  }
2133
- function exitOnNearestSide(box, target) {
2234
+ function getNodeIntersection(intersectionBox, targetCenter) {
2235
+ const w = intersectionBox.w / 2;
2236
+ const h = intersectionBox.h / 2;
2237
+ const x2 = intersectionBox.x + w;
2238
+ const y2 = intersectionBox.y + h;
2239
+ if (w === 0 || h === 0) return [x2, y2];
2240
+ const x1 = targetCenter[0];
2241
+ const y1 = targetCenter[1];
2242
+ const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
2243
+ const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
2244
+ const denom = Math.abs(xx1) + Math.abs(yy1);
2245
+ if (denom === 0) {
2246
+ return [intersectionBox.x + intersectionBox.w, y2];
2247
+ }
2248
+ const a = 1 / denom;
2249
+ const xx3 = a * xx1;
2250
+ const yy3 = a * yy1;
2251
+ const x = w * (xx3 + yy3) + x2;
2252
+ const y = h * (-xx3 + yy3) + y2;
2253
+ return [x, y];
2254
+ }
2255
+ function getEdgePosition(box, intersectionPoint) {
2256
+ const nx = Math.round(box.x);
2257
+ const ny = Math.round(box.y);
2258
+ const px = Math.round(intersectionPoint[0]);
2259
+ const py = Math.round(intersectionPoint[1]);
2260
+ if (px <= nx + 1) return "left";
2261
+ if (px >= nx + box.w - 1) return "right";
2262
+ if (py <= ny + 1) return "top";
2263
+ if (py >= ny + box.h - 1) return "bottom";
2264
+ return "top";
2265
+ }
2266
+ function resolveEdgeEndpoint(sourceBox, targetBox, padding = 0) {
2267
+ const targetCenter = [targetBox.x + targetBox.w / 2, targetBox.y + targetBox.h / 2];
2268
+ const raw = getNodeIntersection(sourceBox, targetCenter);
2269
+ const side = getEdgePosition(sourceBox, raw);
2270
+ if (padding === 0) return { point: raw, side };
2271
+ let point = raw;
2272
+ switch (side) {
2273
+ case "right":
2274
+ point = [raw[0] + padding, raw[1]];
2275
+ break;
2276
+ case "left":
2277
+ point = [raw[0] - padding, raw[1]];
2278
+ break;
2279
+ case "bottom":
2280
+ point = [raw[0], raw[1] + padding];
2281
+ break;
2282
+ case "top":
2283
+ point = [raw[0], raw[1] - padding];
2284
+ break;
2285
+ }
2286
+ return { point, side };
2287
+ }
2288
+ function inferSide(box, anchor, opposite, tolerance = 1) {
2289
+ const onLeft = Math.abs(anchor[0] - box.x) <= tolerance;
2290
+ const onRight = Math.abs(anchor[0] - (box.x + box.w)) <= tolerance;
2291
+ const onTop = Math.abs(anchor[1] - box.y) <= tolerance;
2292
+ const onBottom = Math.abs(anchor[1] - (box.y + box.h)) <= tolerance;
2293
+ const horizontalHit = onLeft || onRight;
2294
+ const verticalHit = onTop || onBottom;
2295
+ if (horizontalHit && verticalHit) {
2296
+ const cx = box.x + box.w / 2;
2297
+ const cy = box.y + box.h / 2;
2298
+ const dx = opposite[0] - cx;
2299
+ const dy = opposite[1] - cy;
2300
+ if (Math.abs(dx) >= Math.abs(dy)) {
2301
+ return { side: onRight ? "right" : "left", confident: true };
2302
+ }
2303
+ return { side: onBottom ? "bottom" : "top", confident: true };
2304
+ }
2305
+ if (onLeft) return { side: "left", confident: true };
2306
+ if (onRight) return { side: "right", confident: true };
2307
+ if (onTop) return { side: "top", confident: true };
2308
+ if (onBottom) return { side: "bottom", confident: true };
2309
+ return { side: nearestSide(box, opposite), confident: false };
2310
+ }
2311
+ function exitOnNearestSide(box, target, anchor) {
2134
2312
  const cx = box.x + box.w / 2;
2135
2313
  const cy = box.y + box.h / 2;
2314
+ if (anchor) {
2315
+ const tolerance = 1;
2316
+ const onLeft = Math.abs(anchor[0] - box.x) <= tolerance;
2317
+ const onRight = Math.abs(anchor[0] - (box.x + box.w)) <= tolerance;
2318
+ const onTop = Math.abs(anchor[1] - box.y) <= tolerance;
2319
+ const onBottom = Math.abs(anchor[1] - (box.y + box.h)) <= tolerance;
2320
+ const horizontalHit = onLeft || onRight;
2321
+ const verticalHit = onTop || onBottom;
2322
+ if (horizontalHit && verticalHit) {
2323
+ const dx2 = target[0] - cx;
2324
+ const dy2 = target[1] - cy;
2325
+ if (Math.abs(dx2) >= Math.abs(dy2)) {
2326
+ return onRight ? { point: [box.x + box.w, cy], side: "right" } : { point: [box.x, cy], side: "left" };
2327
+ }
2328
+ return onBottom ? { point: [cx, box.y + box.h], side: "bottom" } : { point: [cx, box.y], side: "top" };
2329
+ }
2330
+ if (onRight) return { point: [box.x + box.w, cy], side: "right" };
2331
+ if (onLeft) return { point: [box.x, cy], side: "left" };
2332
+ if (onBottom) return { point: [cx, box.y + box.h], side: "bottom" };
2333
+ if (onTop) return { point: [cx, box.y], side: "top" };
2334
+ }
2136
2335
  const dx = target[0] - cx;
2137
2336
  const dy = target[1] - cy;
2138
2337
  if (Math.abs(dx) >= Math.abs(dy)) {
@@ -3635,8 +3834,12 @@ function createCalendar(core, options) {
3635
3834
  createTypedCore,
3636
3835
  createVirtual,
3637
3836
  detectConflicts,
3837
+ floatingAnchor,
3638
3838
  freezeMiddleware,
3639
3839
  getBezierPath,
3840
+ getEdgePosition,
3841
+ getNodeIntersection,
3842
+ inferSide,
3640
3843
  isAvailable,
3641
3844
  listReservedPrefixes,
3642
3845
  loggerMiddleware,
@@ -3649,6 +3852,7 @@ function createCalendar(core, options) {
3649
3852
  pointsToPath,
3650
3853
  pointsToRoundedPath,
3651
3854
  reservePrefix,
3855
+ resolveEdgeEndpoint,
3652
3856
  resolveOperator,
3653
3857
  routeOrthogonal,
3654
3858
  validationMiddleware