@typeonce/effect-machine-devtools 0.31.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/MachineRegistry.d.ts +4 -3
  2. package/dist/MachineRegistry.d.ts.map +1 -1
  3. package/dist/MachineRegistry.js +4 -3
  4. package/dist/MachineRegistry.js.map +1 -1
  5. package/dist/client/assets/index-2uvShdlX.js +37 -0
  6. package/dist/client/index.html +1 -1
  7. package/dist/internal/devServer.d.ts +3 -1
  8. package/dist/internal/devServer.d.ts.map +1 -1
  9. package/dist/internal/devServer.js +8 -6
  10. package/dist/internal/devServer.js.map +1 -1
  11. package/dist/internal/machineRegistry.js +9 -6
  12. package/dist/internal/machineRegistry.js.map +1 -1
  13. package/package.json +2 -2
  14. package/src/MachineRegistry.ts +4 -3
  15. package/src/internal/browser/chart-layout-policy.ts +2 -2
  16. package/src/internal/browser/chart-layout.ts +236 -51
  17. package/src/internal/browser/chart-model.ts +3 -0
  18. package/src/internal/browser/chart-renderer.ts +5 -4
  19. package/src/internal/browser/example-machine.ts +94 -85
  20. package/src/internal/browser/hierarchy-routing-example.ts +67 -58
  21. package/src/internal/browser/invoke-outcomes-example.ts +153 -143
  22. package/src/internal/browser/layout-resilience-example.ts +115 -104
  23. package/src/internal/browser/parallel-completion-example.ts +117 -107
  24. package/src/internal/browser/parallel-owner-routing-example.ts +104 -0
  25. package/src/internal/browser/planner-example.ts +44 -38
  26. package/src/internal/browser/protocol-events-example.ts +123 -102
  27. package/src/internal/browser/shared-terminal-routing-example.ts +125 -120
  28. package/src/internal/browser/text-tree.ts +2 -2
  29. package/src/internal/browser/transition-semantics-example.ts +143 -131
  30. package/src/internal/browser/visualizer-model.ts +7 -7
  31. package/src/internal/devServer.ts +15 -12
  32. package/src/internal/machineRegistry.ts +17 -17
  33. package/dist/client/assets/index-CSgOYHLh.js +0 -37
@@ -10,12 +10,23 @@ import type {
10
10
  } from "elkjs/lib/elk-api.js"
11
11
  import ELKBundle from "elkjs/lib/elk.bundled.js"
12
12
  import { type ChartPortSide, makeChartLayoutPolicy } from "./chart-layout-policy.js"
13
- import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js"
13
+ import {
14
+ type ChartEdge,
15
+ type ChartInitial,
16
+ type ChartModel,
17
+ type ChartNode,
18
+ type ChartRuntimeTarget,
19
+ isChartStateDescendant
20
+ } from "./chart-model.js"
14
21
 
15
22
  export const maxVisibleActivities = 3
16
23
  export const chartSelfLoopMinimumClearance = 24
17
24
  export const chartEdgeLabelSpacing = 5
18
25
 
26
+ const initialMarkerTopOffset = 17
27
+ const initialMarkerSize = 7
28
+ const initialMarkerRouteClearance = 12
29
+
19
30
  export interface ChartPoint {
20
31
  readonly x: number
21
32
  readonly y: number
@@ -259,8 +270,6 @@ const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}
259
270
  const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
260
271
  const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}`
261
272
  const isSelfTransition = (edge: ChartEdge): boolean => edge.kind === "targetless" || edge.target === edge.source
262
- const isDescendantPath = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`)
263
-
264
273
  const selfLoopsBySource = (edges: ReadonlyArray<ChartEdge>): ReadonlyMap<string, number> => {
265
274
  const counts = new Map<string, number>()
266
275
  for (const edge of edges) {
@@ -380,7 +389,7 @@ const makeGraph = (
380
389
  id: node.path,
381
390
  ports: [...ports.get(node.path) ?? []],
382
391
  layoutOptions: {
383
- ...(profile.portConstraints === "fixed" && node.children.length === 0
392
+ ...(profile.portConstraints === "fixed"
384
393
  ? { "elk.portConstraints": "FIXED_SIDE" }
385
394
  : {}),
386
395
  "elk.spacing.portPort": "24",
@@ -508,6 +517,17 @@ const add = (left: ChartPoint, right: ChartPoint): ChartPoint => ({
508
517
  y: left.y + right.y
509
518
  })
510
519
 
520
+ const between = (value: number, first: number, second: number): boolean =>
521
+ value >= Math.min(first, second) && value <= Math.max(first, second)
522
+
523
+ const redundantCollinearPoint = (
524
+ start: ChartPoint,
525
+ middle: ChartPoint,
526
+ end: ChartPoint
527
+ ): boolean =>
528
+ start.x === middle.x && middle.x === end.x && between(middle.y, start.y, end.y) ||
529
+ start.y === middle.y && middle.y === end.y && between(middle.x, start.x, end.x)
530
+
511
531
  const compactPoints = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
512
532
  const result: Array<ChartPoint> = []
513
533
  for (const point of points) {
@@ -516,8 +536,7 @@ const compactPoints = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPo
516
536
  const beforePrevious = result.at(-2)
517
537
  if (
518
538
  beforePrevious !== undefined && previous !== undefined &&
519
- (beforePrevious.x === previous.x && previous.x === point.x ||
520
- beforePrevious.y === previous.y && previous.y === point.y)
539
+ redundantCollinearPoint(beforePrevious, previous, point)
521
540
  ) {
522
541
  result[result.length - 1] = point
523
542
  } else {
@@ -589,7 +608,7 @@ const horizontalBoundaryIntersection = (
589
608
  return { x: start.x <= end.x ? minimum : maximum, y }
590
609
  }
591
610
 
592
- const trimRouteFromCompoundHeader = (
611
+ const anchorRouteAtCompoundHeader = (
593
612
  points: ReadonlyArray<ChartPoint>,
594
613
  node: LaidOutChartNode
595
614
  ): ReadonlyArray<ChartPoint> => {
@@ -604,25 +623,16 @@ const trimRouteFromCompoundHeader = (
604
623
  )
605
624
  if (intersection !== null) return compactPoints([intersection, ...points.slice(index)])
606
625
  }
607
- return points
608
- }
609
-
610
- const normalizeHierarchyRoute = (
611
- edge: ChartEdge,
612
- points: ReadonlyArray<ChartPoint>,
613
- nodes: ReadonlyMap<string, LaidOutChartNode>
614
- ): ReadonlyArray<ChartPoint> => {
615
- if (edge.target !== null && isDescendantPath(edge.target, edge.source)) {
616
- const source = nodes.get(edge.source)
617
- return source === undefined ? points : trimRouteFromCompoundHeader(points, source)
618
- }
619
- if (edge.target !== null && isDescendantPath(edge.source, edge.target)) {
620
- const target = nodes.get(edge.target)
621
- return target === undefined
622
- ? points
623
- : [...trimRouteFromCompoundHeader([...points].reverse(), target)].reverse()
626
+ const first = points[0]
627
+ const next = points[1]
628
+ if (first === undefined || next === undefined) return points
629
+ const anchor = {
630
+ x: Math.min(node.x + node.width, Math.max(node.x, first.x)),
631
+ y: boundary
624
632
  }
625
- return points
633
+ return first.x === next.x
634
+ ? compactPoints([anchor, ...points.slice(1)])
635
+ : compactPoints([anchor, { x: next.x, y: boundary }, ...points.slice(1)])
626
636
  }
627
637
 
628
638
  type RouteSide = "NORTH" | "EAST" | "SOUTH" | "WEST"
@@ -648,6 +658,23 @@ const endpointSide = (point: ChartPoint, node: LaidOutChartNode): RouteSide => {
648
658
  return distances.sort((left, right) => left[1] - right[1])[0]![0]
649
659
  }
650
660
 
661
+ const endpointStepSide = (
662
+ boundary: ChartPoint,
663
+ adjacent: ChartPoint,
664
+ node: LaidOutChartNode
665
+ ): RouteSide => {
666
+ const rect = routeNodeRect(node)
667
+ if (boundary.y === adjacent.y) {
668
+ if (boundary.x === rect.left) return "WEST"
669
+ if (boundary.x === rect.right) return "EAST"
670
+ }
671
+ if (boundary.x === adjacent.x) {
672
+ if (boundary.y === rect.top) return "NORTH"
673
+ if (boundary.y === rect.bottom) return "SOUTH"
674
+ }
675
+ return endpointSide(boundary, node)
676
+ }
677
+
651
678
  const outwardPoint = (point: ChartPoint, side: RouteSide, distance: number): ChartPoint => {
652
679
  switch (side) {
653
680
  case "NORTH":
@@ -741,6 +768,90 @@ const routeIsClear = (
741
768
  obstacles.every((obstacle) => !segmentCrossesInterior(previous, point, obstacle))
742
769
  })
743
770
 
771
+ const localDescendantRoute = (
772
+ edge: ChartEdge,
773
+ points: ReadonlyArray<ChartPoint>,
774
+ source: LaidOutChartNode,
775
+ target: LaidOutChartNode,
776
+ nodes: ReadonlyArray<LaidOutChartNode>,
777
+ lanes: Map<string, number>
778
+ ): ReadonlyArray<ChartPoint> | null => {
779
+ const targetPath = edge.target
780
+ if (
781
+ targetPath === null || source.node.type !== "parallel" ||
782
+ endpointSide(points.at(-1)!, target) !== "NORTH"
783
+ ) return null
784
+ const containers = nodes.filter((node) =>
785
+ node.node.children.length > 0 &&
786
+ node.node.path !== edge.source &&
787
+ node.node.path !== edge.target &&
788
+ isChartStateDescendant(node.node.path, edge.source) &&
789
+ isChartStateDescendant(targetPath, node.node.path)
790
+ )
791
+ if (containers.length === 0) return null
792
+
793
+ const sourceHeader = nodeHeaderRect(source)
794
+ const targetRect = routeNodeRect(target)
795
+ const end = pointOnNodeBoundary(points.at(-1)!, target, "NORTH")
796
+ const targetStub = {
797
+ x: end.x,
798
+ y: targetRect.top - initialMarkerTopOffset - initialMarkerRouteClearance
799
+ }
800
+ if (targetStub.y <= sourceHeader.bottom) return null
801
+
802
+ const containerHeaders = containers.map(nodeHeaderRect)
803
+ const left = Math.min(...containerHeaders.map((header) => header.left))
804
+ const right = Math.max(...containerHeaders.map((header) => header.right))
805
+ const candidates = (["left", "right"] as const).flatMap((side) => {
806
+ const key = `descendant:${edge.source}:${containers[0]!.node.path}:${side}`
807
+ const lane = lanes.get(key) ?? 0
808
+ const x = side === "left" ? left - 12 - lane * 8 : right + 12 + lane * 8
809
+ if (x < sourceHeader.left || x > sourceHeader.right) return []
810
+ const route = compactPoints([
811
+ { x, y: sourceHeader.bottom },
812
+ { x, y: targetStub.y },
813
+ targetStub,
814
+ end
815
+ ])
816
+ return routeIsClear(route, routeObstacles(edge, nodes))
817
+ ? [{ key, lane, route }]
818
+ : []
819
+ })
820
+ const selected =
821
+ candidates.sort((leftCandidate, rightCandidate) =>
822
+ chartRouteLength(leftCandidate.route) + leftCandidate.lane * 8 -
823
+ (chartRouteLength(rightCandidate.route) + rightCandidate.lane * 8)
824
+ )[0]
825
+ if (selected === undefined || chartRouteLength(selected.route) + 16 >= chartRouteLength(points)) return null
826
+ lanes.set(selected.key, selected.lane + 1)
827
+ return selected.route
828
+ }
829
+
830
+ const normalizeHierarchyRoute = (
831
+ edge: ChartEdge,
832
+ points: ReadonlyArray<ChartPoint>,
833
+ nodes: ReadonlyMap<string, LaidOutChartNode>,
834
+ allNodes: ReadonlyArray<LaidOutChartNode>,
835
+ lanes: Map<string, number>
836
+ ): ReadonlyArray<ChartPoint> => {
837
+ if (edge.target !== null && isChartStateDescendant(edge.target, edge.source)) {
838
+ const source = nodes.get(edge.source)
839
+ const target = nodes.get(edge.target)
840
+ if (source === undefined) return points
841
+ return target === undefined
842
+ ? anchorRouteAtCompoundHeader(points, source)
843
+ : localDescendantRoute(edge, points, source, target, allNodes, lanes) ??
844
+ anchorRouteAtCompoundHeader(points, source)
845
+ }
846
+ if (edge.target !== null && isChartStateDescendant(edge.source, edge.target)) {
847
+ const target = nodes.get(edge.target)
848
+ return target === undefined
849
+ ? points
850
+ : [...anchorRouteAtCompoundHeader([...points].reverse(), target)].reverse()
851
+ }
852
+ return points
853
+ }
854
+
744
855
  const sameSideRoute = (
745
856
  points: ReadonlyArray<ChartPoint>,
746
857
  side: RouteSide,
@@ -906,9 +1017,18 @@ const routeLabelCandidates = (
906
1017
  points: ReadonlyArray<ChartPoint>,
907
1018
  fallback: ChartPoint,
908
1019
  width: number,
909
- height: number
1020
+ height: number,
1021
+ nodes: ReadonlyArray<LaidOutChartNode>
910
1022
  ): ReadonlyArray<ChartPoint> => {
911
1023
  if (isSelfTransition(edge)) return [fallback]
1024
+ const source = nodes.find(({ node }) => node.path === edge.source)
1025
+ const target = edge.target === null
1026
+ ? undefined
1027
+ : nodes.find(({ node }) => node.path === edge.target)
1028
+ const localDescendantTarget = source?.node.type === "parallel" && target !== undefined &&
1029
+ target.node.parent !== source.node.path && isChartStateDescendant(target.node.path, source.node.path)
1030
+ ? target
1031
+ : undefined
912
1032
  const candidates = points.slice(1).flatMap((end, index) => {
913
1033
  const start = points[index]!
914
1034
  const horizontal = start.y === end.y
@@ -916,21 +1036,38 @@ const routeLabelCandidates = (
916
1036
  const required = (horizontal ? width : height) + 24
917
1037
  return length < required ? [] : [{ start, end, horizontal, length }]
918
1038
  })
1039
+ const adjacent: Array<ChartPoint> = []
919
1040
  const ordered = [
920
1041
  ...candidates.filter(({ horizontal }) => !horizontal).sort((left, right) => right.length - left.length),
921
1042
  ...candidates.filter(({ horizontal }) => horizontal).sort((left, right) => right.length - left.length)
922
1043
  ].flatMap(({ start, end, horizontal, length }) => {
923
1044
  const clearance = (horizontal ? width : height) / 2 + 12
924
- return [0.5, 2 / 3, 1 / 3].flatMap((ratio) => {
1045
+ return [0.5, 2 / 3, 1 / 3, 0.2, 0.8, 0.1, 0.9].flatMap((ratio) => {
925
1046
  const distance = length * ratio
926
1047
  if (distance < clearance || length - distance < clearance) return []
927
- return [{
1048
+ const onRoute = {
928
1049
  x: start.x + (end.x - start.x) * ratio,
929
1050
  y: start.y + (end.y - start.y) * ratio
930
- }]
1051
+ }
1052
+ // A root container can place two routes in the same corridor. Keep the
1053
+ // label attached beside its own segment when no position on it is clear.
1054
+ for (const direction of [-1, 1]) {
1055
+ adjacent.push(
1056
+ horizontal
1057
+ ? { x: onRoute.x, y: onRoute.y + direction * (height / 2 + chartEdgeLabelSpacing) }
1058
+ : { x: onRoute.x + direction * (width / 2 + chartEdgeLabelSpacing), y: onRoute.y }
1059
+ )
1060
+ }
1061
+ if (horizontal || localDescendantTarget === undefined) return [onRoute]
1062
+ const targetCenter = localDescendantTarget.x + localDescendantTarget.width / 2
1063
+ const direction = targetCenter < onRoute.x ? -1 : 1
1064
+ return [{
1065
+ x: onRoute.x + direction * (width / 2 + chartEdgeLabelSpacing),
1066
+ y: onRoute.y
1067
+ }, onRoute]
931
1068
  })
932
1069
  })
933
- return [...ordered, fallback].filter((candidate, index, all) =>
1070
+ return [...ordered, ...adjacent, fallback].filter((candidate, index, all) =>
934
1071
  all.findIndex((other) => other.x === candidate.x && other.y === candidate.y) === index
935
1072
  )
936
1073
  }
@@ -946,7 +1083,8 @@ const placeTransitionLabels = (
946
1083
  transition.points,
947
1084
  transition.label,
948
1085
  transition.labelWidth,
949
- transition.labelHeight
1086
+ transition.labelHeight,
1087
+ nodes
950
1088
  )
951
1089
  const position = candidates.find((candidate) => {
952
1090
  const candidateRect = labelRect(candidate, transition.labelWidth, transition.labelHeight)
@@ -992,7 +1130,8 @@ const placeTransitionLabels = (
992
1130
  const collectLayout = (
993
1131
  model: ChartModel,
994
1132
  graph: ElkNode,
995
- unconnected: ReadonlyArray<UnconnectedRegion>
1133
+ unconnected: ReadonlyArray<UnconnectedRegion>,
1134
+ shortenRoutes = true
996
1135
  ): LaidOutChart => {
997
1136
  const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
998
1137
  const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
@@ -1052,9 +1191,9 @@ const collectLayout = (
1052
1191
  initials.push({
1053
1192
  initial,
1054
1193
  x: target.x + 9,
1055
- y: target.y - 17,
1056
- width: 7,
1057
- height: 7
1194
+ y: target.y - initialMarkerTopOffset,
1195
+ width: initialMarkerSize,
1196
+ height: initialMarkerSize
1058
1197
  })
1059
1198
  }
1060
1199
 
@@ -1074,9 +1213,9 @@ const collectLayout = (
1074
1213
  chartEdge,
1075
1214
  avoidCompoundHeaders(
1076
1215
  chartEdge,
1077
- shortenTransitionRoute(
1216
+ (shortenRoutes ? shortenTransitionRoute : (_edge: ChartEdge, route: ReadonlyArray<ChartPoint>) => route)(
1078
1217
  chartEdge,
1079
- normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath),
1218
+ normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath, nodes, hierarchyLanes),
1080
1219
  nodesByPath,
1081
1220
  nodes,
1082
1221
  directLanes
@@ -1129,31 +1268,65 @@ const collectLayout = (
1129
1268
  ...transitionEdges,
1130
1269
  ...initialEdges
1131
1270
  ]
1132
- const contentWidth = Math.max(
1271
+ const contentLeft = Math.min(
1272
+ ...nodes.map(({ x }) => x),
1273
+ ...regions.map(({ x }) => x),
1274
+ ...initials.map(({ x }) => x),
1275
+ ...runtimeTargets.map(({ x }) => x),
1276
+ ...edges.flatMap(({ points }) => points.map(({ x }) => x)),
1277
+ ...transitionEdges.map(({ label, labelWidth }) => label.x - labelWidth / 2)
1278
+ )
1279
+ const contentTop = Math.min(
1280
+ ...nodes.map(({ y }) => y),
1281
+ ...regions.map(({ y }) => y),
1282
+ ...initials.map(({ y }) => y),
1283
+ ...runtimeTargets.map(({ y }) => y),
1284
+ ...edges.flatMap(({ points }) => points.map(({ y }) => y)),
1285
+ ...transitionEdges.map(({ label, labelHeight }) => label.y - labelHeight / 2)
1286
+ )
1287
+ const contentRight = Math.max(
1133
1288
  0,
1134
1289
  ...nodes.map(({ width, x }) => x + width),
1290
+ ...regions.map(({ width, x }) => x + width),
1135
1291
  ...initials.map(({ width, x }) => x + width),
1136
1292
  ...runtimeTargets.map(({ width, x }) => x + width),
1137
1293
  ...edges.flatMap(({ points }) => points.map(({ x }) => x)),
1138
1294
  ...transitionEdges.map(({ label, labelWidth }) => label.x + labelWidth / 2)
1139
1295
  )
1140
- const contentHeight = Math.max(
1296
+ const contentBottom = Math.max(
1141
1297
  0,
1142
1298
  ...nodes.map(({ height, y }) => y + height),
1299
+ ...regions.map(({ height, y }) => y + height),
1143
1300
  ...initials.map(({ height, y }) => y + height),
1144
1301
  ...runtimeTargets.map(({ height, y }) => y + height),
1145
1302
  ...edges.flatMap(({ points }) => points.map(({ y }) => y)),
1146
1303
  ...transitionEdges.map(({ label, labelHeight }) => label.y + labelHeight / 2)
1147
1304
  )
1305
+ const shiftX = Math.max(0, 20 - contentLeft)
1306
+ const shiftY = Math.max(0, 20 - contentTop)
1307
+ const translatePoint = ({ x, y }: ChartPoint): ChartPoint => ({ x: x + shiftX, y: y + shiftY })
1308
+ const translatedTransitionEdges = transitionEdges.map((edge): LaidOutChartTransition => ({
1309
+ ...edge,
1310
+ points: edge.points.map(translatePoint),
1311
+ label: translatePoint(edge.label)
1312
+ }))
1313
+ const translatedInitialEdges = initialEdges.map((edge): LaidOutChartInitialEdge => ({
1314
+ ...edge,
1315
+ points: edge.points.map(translatePoint)
1316
+ }))
1148
1317
 
1149
1318
  return {
1150
- width: Math.max(360, graph.width ?? 0, contentWidth + 20),
1151
- height: Math.max(280, graph.height ?? 0, contentHeight + 20),
1152
- regions,
1153
- nodes,
1154
- initials,
1155
- runtimeTargets,
1156
- edges
1319
+ width: Math.max(360, (graph.width ?? 0) + shiftX, contentRight + shiftX + 20),
1320
+ height: Math.max(280, (graph.height ?? 0) + shiftY, contentBottom + shiftY + 20),
1321
+ regions: regions.map((region) => ({ ...region, x: region.x + shiftX, y: region.y + shiftY })),
1322
+ nodes: nodes.map((node) => ({ ...node, x: node.x + shiftX, y: node.y + shiftY })),
1323
+ initials: initials.map((initial) => ({ ...initial, x: initial.x + shiftX, y: initial.y + shiftY })),
1324
+ runtimeTargets: runtimeTargets.map((target) => ({
1325
+ ...target,
1326
+ x: target.x + shiftX,
1327
+ y: target.y + shiftY
1328
+ })),
1329
+ edges: [...translatedTransitionEdges, ...translatedInitialEdges]
1157
1330
  }
1158
1331
  }
1159
1332
 
@@ -1299,8 +1472,8 @@ const collinearOverlap = (left: OrthogonalSegment, right: OrthogonalSegment): nu
1299
1472
 
1300
1473
  const transitionTouchesNode = (edge: ChartEdge, path: string): boolean => {
1301
1474
  const target = edgeTargetPath(edge)
1302
- return edge.source === path || isDescendantPath(edge.source, path) ||
1303
- target === path || target !== null && isDescendantPath(target, path)
1475
+ return edge.source === path || isChartStateDescendant(edge.source, path) ||
1476
+ target === path || target !== null && isChartStateDescendant(target, path)
1304
1477
  }
1305
1478
 
1306
1479
  const laidOutEdgeId = (edge: LaidOutChartTransition | LaidOutChartInitialEdge): string =>
@@ -1348,7 +1521,7 @@ export const validateChartLayout = (
1348
1521
  }
1349
1522
  if (nodeBoundaryDistance(start, source) > 0.5) {
1350
1523
  report("detached-source", transition.edge.id, transition.edge.source)
1351
- } else if (!isOutwardStep(start, next, endpointSide(start, source))) {
1524
+ } else if (!isOutwardStep(start, next, endpointStepSide(start, next, source))) {
1352
1525
  report("wrong-source-direction", transition.edge.id, transition.edge.source)
1353
1526
  }
1354
1527
  }
@@ -1364,7 +1537,10 @@ export const validateChartLayout = (
1364
1537
  const target = layout.nodes.find(({ node }) => node.path === transition.edge.target)
1365
1538
  if (target !== undefined && nodeBoundaryDistance(end, target) > 0.5) {
1366
1539
  report("detached-terminal", transition.edge.id, transition.edge.target)
1367
- } else if (target !== undefined && bend !== undefined && !isOutwardStep(end, bend, endpointSide(end, target))) {
1540
+ } else if (
1541
+ target !== undefined && bend !== undefined &&
1542
+ !isOutwardStep(end, bend, endpointStepSide(end, bend, target))
1543
+ ) {
1368
1544
  report("wrong-terminal-direction", transition.edge.id, transition.edge.target)
1369
1545
  }
1370
1546
  }
@@ -1417,7 +1593,7 @@ export const validateChartLayout = (
1417
1593
  for (const initial of initialEdges) {
1418
1594
  for (const node of layout.nodes) {
1419
1595
  const containsTarget = node.node.path === initial.initial.target ||
1420
- isDescendantPath(initial.initial.target, node.node.path)
1596
+ isChartStateDescendant(initial.initial.target, node.node.path)
1421
1597
  const obstacle = node.node.children.length > 0 && containsTarget
1422
1598
  ? nodeHeaderRect(node)
1423
1599
  : nodeRect(node)
@@ -1446,11 +1622,14 @@ export const validateChartLayout = (
1446
1622
  }
1447
1623
 
1448
1624
  const allSegments = segments(layout)
1625
+ const transitionIds = new Map(model.edges.map((edge) => [edge.id, edge.transitionId]))
1449
1626
  for (let left = 0; left < allSegments.length; left++) {
1450
1627
  for (let right = left + 1; right < allSegments.length; right++) {
1451
1628
  const first = allSegments[left]!
1452
1629
  const second = allSegments[right]!
1453
1630
  if (first.edgeId === second.edgeId) continue
1631
+ const firstTransition = transitionIds.get(first.edgeId)
1632
+ if (firstTransition !== undefined && firstTransition === transitionIds.get(second.edgeId)) continue
1454
1633
  if (collinearOverlap(first, second) > 4) report("route-overlap", first.edgeId, second.edgeId)
1455
1634
  }
1456
1635
  }
@@ -1540,6 +1719,12 @@ export const layoutChartWith = (
1540
1719
  const validation = validate(model, candidate)
1541
1720
  if (validation.valid) return Effect.succeed(candidate)
1542
1721
  invalid.push({ profile: profile.id, layout: candidate, validation })
1722
+ // Route shortening can crowd labels beside unrelated transitions.
1723
+ // Keep the original ELK corridor as a checked alternative.
1724
+ const originalRoutes = collectLayout(model, graph, regions, false)
1725
+ const originalValidation = validate(model, originalRoutes)
1726
+ if (originalValidation.valid) return Effect.succeed(originalRoutes)
1727
+ invalid.push({ profile: profile.id, layout: originalRoutes, validation: originalValidation })
1543
1728
  return attempt(index + 1)
1544
1729
  }
1545
1730
  }
@@ -71,6 +71,9 @@ export interface ChartModel {
71
71
  readonly initials: ReadonlyArray<ChartInitial>
72
72
  }
73
73
 
74
+ export const isChartStateDescendant = (path: string, ancestor: string): boolean =>
75
+ ancestor === "" ? path !== "" : path.startsWith(`${ancestor}.`)
76
+
74
77
  const activityLabel = (activity: VisualizationActivity): string => {
75
78
  switch (activity.type) {
76
79
  case "machine":
@@ -8,7 +8,7 @@ import {
8
8
  layoutChart,
9
9
  maxVisibleActivities
10
10
  } from "./chart-layout.js"
11
- import { type ChartEdgeBadge, makeChartModel } from "./chart-model.js"
11
+ import { type ChartEdge, type ChartEdgeBadge, isChartStateDescendant, makeChartModel } from "./chart-model.js"
12
12
 
13
13
  export interface ChartHandlers {
14
14
  readonly selectState: (path: string, anchor: ChartInteractionAnchor) => void
@@ -60,6 +60,9 @@ export const minimumChartZoom = 0.2
60
60
  export const maximumChartZoom = 1.6
61
61
  export const chartPanThreshold = 5
62
62
 
63
+ export const isParentChildTransition = (edge: ChartEdge): boolean =>
64
+ edge.target !== null && isChartStateDescendant(edge.target, edge.source)
65
+
63
66
  const clampZoom = (zoom: number): number => Math.min(maximumChartZoom, Math.max(minimumChartZoom, zoom))
64
67
 
65
68
  export const chartZoomScrollPosition = (
@@ -384,7 +387,6 @@ const render = (
384
387
  const transitionElements = new Map<string, Array<Element>>()
385
388
  const edgeElements = new Map<string, Array<Element>>()
386
389
  const transitionControls = new Map<string, Array<HTMLButtonElement>>()
387
- const parentByState = new Map(layout.nodes.map(({ node }) => [node.path, node.parent]))
388
390
  const chartEdges = new Map(
389
391
  layout.edges.flatMap((laidOut) => laidOut.kind === "transition" ? [[laidOut.edge.id, laidOut.edge] as const] : [])
390
392
  )
@@ -474,8 +476,7 @@ const render = (
474
476
  }
475
477
 
476
478
  for (const laidOut of layout.edges) {
477
- const parentChild = laidOut.kind === "transition" && laidOut.edge.target !== null &&
478
- parentByState.get(laidOut.edge.target) === laidOut.edge.source
479
+ const parentChild = laidOut.kind === "transition" && isParentChildTransition(laidOut.edge)
479
480
  const failure = laidOut.kind === "transition" &&
480
481
  laidOut.edge.badges.some((badge) => badge.type === "failure")
481
482
  const group = svgElement(