Răsfoiți Sursa

fix(merman): tighten flowchart spacing (#41191)

Kit Langton 2 zile în urmă
părinte
comite
dd6020656e

+ 139 - 14
packages/merman/src/flowchart/flowchart.test.ts

@@ -198,13 +198,13 @@ describe("FlowchartDiagram", () => {
   B --> A`)
 
     expectDiagram(output).toEqualDiagram(`
-        ╭──────────────
-        │              
-        │              
-        ▼              
-      ╭───╮          ╭─┴─╮
-      │ A ├─────────▶│ B │
-      ╰───╯          ╰───╯
+        ╭───────────╮
+        │           │
+        │           │
+        ▼           │
+      ╭───╮       ╭─┴─╮
+      │ A ├──────▶│ B │
+      ╰───╯       ╰───╯
     `)
   })
 
@@ -599,7 +599,7 @@ flowchart LR
     const output = renderFlowchartDiagram(content)
 
     expect(diagram.edges).toEqual([{ from: "Build", to: "Ship", label: "", style: "thick" }])
-    expect(output).toContain("━━━━━━━━━▶")
+    expect(output).toContain("━━━━━━▶")
   })
 
   test("parses and renders Mermaid dashed edges", () => {
@@ -611,7 +611,7 @@ flowchart LR
     const output = renderFlowchartDiagram(content)
 
     expect(diagram.edges).toEqual([{ from: "Build", to: "Ship", label: "", style: "dashed" }])
-    expect(output).toContain("─────────▶")
+    expect(output).toContain("──────▶")
   })
 
   test("paints horizontal and vertical dashed routes with solid terminal cells", () => {
@@ -689,11 +689,11 @@ graph LR
 `)
 
     expectDiagram(output).toEqualDiagram(`
-                                           ╭───────╮
-      ╭────────╮          ╭─────╮          ├───────┤
-      │ Client ├─────────▶│ API ├─────────▶│ Cache │
-      ╰────────╯          ╰─────╯          ├───────┤
-                                           ╰───────╯
+                                     ╭───────╮
+      ╭────────╮       ╭─────╮       ├───────┤
+      │ Client ├──────▶│ API ├──────▶│ Cache │
+      ╰────────╯       ╰─────╯       ├───────┤
+                                     ╰───────╯
     `)
   })
 
@@ -1062,6 +1062,131 @@ flowchart LR
     }
   })
 
+  test.each(["TD", "BT", "LR", "RL"] as const)(
+    "keeps parallel top-level subgraphs in the same rank for %s diagrams",
+    (direction) => {
+      const layout = layoutFlowchartDiagram(`flowchart ${direction}
+  subgraph source [Source]
+    A[A]
+  end
+  subgraph left [Left]
+    B[B]
+  end
+  subgraph right [Right]
+    C[C]
+  end
+  A --> B
+  A --> C`)
+      const source = layout.subgraphBounds.get("source")!
+      const left = layout.subgraphBounds.get("left")!
+      const right = layout.subgraphBounds.get("right")!
+      const leftNode = layout.bounds.get("B")!
+      const rightNode = layout.bounds.get("C")!
+      const horizontal = direction === "LR" || direction === "RL"
+      const reversed = direction === "BT" || direction === "RL"
+      const start = (bound: typeof source) => {
+        const value = horizontal ? bound.left : bound.top
+        const size = horizontal ? bound.width : bound.height
+        return reversed ? -(value + size) : value
+      }
+      const size = (bound: typeof source) => (horizontal ? bound.width : bound.height)
+
+      expect(horizontal ? leftNode.centerX : leftNode.centerY).toBe(horizontal ? rightNode.centerX : rightNode.centerY)
+      expect(Math.min(start(left), start(right))).toBeGreaterThanOrEqual(start(source) + size(source))
+    },
+  )
+
+  test.each(["TD", "BT", "LR", "RL"] as const)(
+    "separates oversized parallel subgraph labels across %s diagrams",
+    (direction) => {
+      const horizontal = direction === "LR" || direction === "RL"
+      const label = horizontal ? "one<br/>two<br/>three<br/>four<br/>five" : "A very very wide downstream subgraph title"
+      const layout = layoutFlowchartDiagram(`flowchart ${direction}
+  subgraph source [Source]
+    A[A]
+  end
+  subgraph left [${label}]
+    B[B]
+  end
+  subgraph right [Right]
+    C[C]
+  end
+  A --> B
+  A --> C`)
+      const left = layout.subgraphBounds.get("left")!
+      const right = layout.subgraphBounds.get("right")!
+      const overlap =
+        left.left < right.left + right.width &&
+        left.left + left.width > right.left &&
+        left.top < right.top + right.height &&
+        left.top + left.height > right.top
+
+      expect(overlap).toBe(false)
+    },
+  )
+
+  test.each(["TD", "BT", "LR", "RL"] as const)(
+    "ranks nested order-only subgraph dependencies for %s diagrams",
+    (direction) => {
+      const layout = layoutFlowchartDiagram(`flowchart ${direction}
+  subgraph first [First]
+    subgraph firstInner [First inner]
+      A[A]
+    end
+  end
+  subgraph second [Second]
+    subgraph secondInner [Second inner]
+      B[B]
+    end
+  end
+  firstInner ~~~ secondInner`)
+      const first = layout.subgraphBounds.get("first")!
+      const second = layout.subgraphBounds.get("second")!
+      const horizontal = direction === "LR" || direction === "RL"
+      const reversed = direction === "BT" || direction === "RL"
+      const start = (bound: typeof first) => {
+        const value = horizontal ? bound.left : bound.top
+        const size = horizontal ? bound.width : bound.height
+        return reversed ? -(value + size) : value
+      }
+      const size = horizontal ? first.width : first.height
+
+      expect(start(second)).toBeGreaterThanOrEqual(start(first) + size)
+    },
+  )
+
+  test.each(["TD", "BT", "LR", "RL"] as const)(
+    "ranks cyclic top-level subgraphs as one downstream component for %s diagrams",
+    (direction) => {
+      const layout = layoutFlowchartDiagram(`flowchart ${direction}
+  subgraph source [Source]
+    A[A]
+  end
+  subgraph first [First]
+    B[B]
+  end
+  subgraph second [Second]
+    C[C]
+  end
+  A --> B
+  B --> C
+  C --> B`)
+      const source = layout.subgraphBounds.get("source")!
+      const first = layout.subgraphBounds.get("first")!
+      const second = layout.subgraphBounds.get("second")!
+      const horizontal = direction === "LR" || direction === "RL"
+      const reversed = direction === "BT" || direction === "RL"
+      const start = (bound: typeof source) => {
+        const value = horizontal ? bound.left : bound.top
+        const size = horizontal ? bound.width : bound.height
+        return reversed ? -(value + size) : value
+      }
+      const size = (bound: typeof source) => (horizontal ? bound.width : bound.height)
+
+      expect(Math.min(start(first), start(second))).toBeGreaterThanOrEqual(start(source) + size(source))
+    },
+  )
+
   test("moves subgraph labels away from crossing routes", () => {
     const output = renderFlowchartDiagram(`
 flowchart TD

+ 142 - 45
packages/merman/src/flowchart/layout.ts

@@ -27,7 +27,7 @@ import type {
 
 export const DEFAULT_MIN_NODE_GAP = 5
 export const DEFAULT_MIN_BRANCH_LABEL_GAP = 12
-export const DEFAULT_MIN_RANK_GAP = 10
+export const DEFAULT_MIN_RANK_GAP = 7
 export const DEFAULT_MIN_VERTICAL_RANK_GAP = 4
 export const COMPACT_MIN_RANK_GAP = 4
 export const COMPACT_MIN_VERTICAL_RANK_GAP = 2
@@ -511,19 +511,79 @@ function collectSubgraphNodeIds(diagram: FlowchartDiagram, subgraphId: string):
   return nodeIds
 }
 
+function rankGraphComponents(ids: readonly string[], outgoing: ReadonlyMap<string, ReadonlySet<string>>): Map<string, number> {
+  const reachable = new Map<string, Set<string>>()
+  for (const id of ids) {
+    const seen = new Set<string>()
+    const queue = [id]
+    for (let index = 0; index < queue.length; index++) {
+      const current = queue[index]!
+      if (seen.has(current)) continue
+      seen.add(current)
+      queue.push(...(outgoing.get(current) ?? []))
+    }
+    reachable.set(id, seen)
+  }
+
+  const componentById = new Map<string, number>()
+  const components: string[][] = []
+  for (const id of ids) {
+    if (componentById.has(id)) continue
+    const component = ids.filter(
+      (candidate) => !componentById.has(candidate) && reachable.get(id)!.has(candidate) && reachable.get(candidate)!.has(id),
+    )
+    const componentIndex = components.length
+    components.push(component)
+    for (const member of component) componentById.set(member, componentIndex)
+  }
+
+  const componentOutgoing = new Map(components.map((_, index) => [index, new Set<number>()]))
+  const incoming = new Map(components.map((_, index) => [index, 0]))
+  for (const [from, targets] of outgoing) {
+    const fromComponent = componentById.get(from)!
+    for (const to of targets) {
+      const toComponent = componentById.get(to)!
+      if (fromComponent === toComponent || componentOutgoing.get(fromComponent)!.has(toComponent)) continue
+      componentOutgoing.get(fromComponent)!.add(toComponent)
+      incoming.set(toComponent, incoming.get(toComponent)! + 1)
+    }
+  }
+
+  const componentRanks = new Map<number, number>()
+  const queue = components.map((_, index) => index).filter((index) => incoming.get(index) === 0)
+  for (const component of queue) componentRanks.set(component, 0)
+  for (let index = 0; index < queue.length; index++) {
+    const component = queue[index]!
+    for (const to of componentOutgoing.get(component)!) {
+      componentRanks.set(to, Math.max(componentRanks.get(to) ?? 0, componentRanks.get(component)! + 1))
+      incoming.set(to, incoming.get(to)! - 1)
+      if (incoming.get(to) === 0) queue.push(to)
+    }
+  }
+
+  return new Map(ids.map((id) => [id, componentRanks.get(componentById.get(id)!) ?? 0]))
+}
+
 function separateTopLevelItems(
   diagram: FlowchartDiagram,
   nodeBounds: Map<string, FlowchartNodeBounds>,
   subgraphBounds: ReadonlyMap<string, FlowchartSubgraphBounds>,
   gap: number,
-): void {
+): boolean {
   const hasLocalDirection = (diagram.subgraphs ?? []).some(
     (subgraph) => subgraph.direction && subgraph.direction !== diagram.direction,
   )
   const coveredNodeIds = new Set<string>()
   const items: { id: string; bounds: FlowchartBounds; nodeIds: Set<string>; rank: number }[] = []
   const itemByEndpoint = new Map<string, string>()
-  for (const subgraph of diagram.subgraphs ?? []) {
+  const subgraphs = diagram.subgraphs ?? []
+  const subgraphById = new Map(subgraphs.map((subgraph) => [subgraph.id, subgraph]))
+  const topLevelSubgraphId = (id: string): string => {
+    let current = subgraphById.get(id)
+    while (current?.parentId) current = subgraphById.get(current.parentId)
+    return current?.id ?? id
+  }
+  for (const subgraph of subgraphs) {
     if (subgraph.parentId) continue
     const bounds = subgraphBounds.get(subgraph.id)
     const nodeIds = collectSubgraphNodeIds(diagram, subgraph.id)
@@ -535,6 +595,7 @@ function separateTopLevelItems(
       itemByEndpoint.set(nodeId, subgraph.id)
     }
   }
+  for (const subgraph of subgraphs) itemByEndpoint.set(subgraph.id, topLevelSubgraphId(subgraph.id))
 
   for (const node of diagram.nodes) {
     if (coveredNodeIds.has(node.id)) continue
@@ -543,12 +604,19 @@ function separateTopLevelItems(
     items.push({ id: node.id, bounds, nodeIds: new Set([node.id]), rank: 0 })
     itemByEndpoint.set(node.id, node.id)
   }
-  if (items.length < 2) return
+  if (items.length < 2) return false
 
   const horizontal = isHorizontalDirection(diagram.direction)
+  const moveItem = (item: (typeof items)[number], dx: number, dy: number): void => {
+    for (const nodeId of item.nodeIds) {
+      const bounds = nodeBounds.get(nodeId)
+      if (bounds) translateBounds(bounds, dx, dy)
+    }
+  }
   if (hasLocalDirection) {
     items.sort((a, b) => (horizontal ? a.bounds.left - b.bounds.left : a.bounds.top - b.bounds.top))
     let cursor: number | undefined
+    let moved = false
     for (const item of items) {
       const start = horizontal ? item.bounds.left : item.bounds.top
       const size = horizontal ? item.bounds.width : item.bounds.height
@@ -557,42 +625,31 @@ function separateTopLevelItems(
         continue
       }
       const shift = cursor - start
-      for (const nodeId of item.nodeIds) {
-        const bounds = nodeBounds.get(nodeId)
-        if (bounds) translateBounds(bounds, horizontal ? shift : 0, horizontal ? 0 : shift)
-      }
+      moved ||= shift !== 0
+      moveItem(item, horizontal ? shift : 0, horizontal ? 0 : shift)
       cursor = start + shift + size + gap
     }
-    return
+    return moved
   }
 
-  const topLevelIds = new Set(
-    (diagram.subgraphs ?? []).filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id),
-  )
+  const topLevelIds = new Set(subgraphs.filter((subgraph) => !subgraph.parentId).map((subgraph) => subgraph.id))
   const rankedItems = items.filter((item) => topLevelIds.has(item.id))
-  if (rankedItems.length < 2) return
+  if (rankedItems.length < 2) return false
 
   const itemById = new Map(rankedItems.map((item) => [item.id, item]))
   const outgoing = new Map(rankedItems.map((item) => [item.id, new Set<string>()]))
-  const incoming = new Map(rankedItems.map((item) => [item.id, 0]))
   for (const edge of diagram.edges) {
     const from = itemByEndpoint.get(edge.from)
     const to = itemByEndpoint.get(edge.to)
     if (!from || !to || from === to || !itemById.has(from) || !itemById.has(to) || outgoing.get(from)!.has(to)) continue
     outgoing.get(from)!.add(to)
-    incoming.set(to, incoming.get(to)! + 1)
   }
 
-  const queue = rankedItems.filter((item) => incoming.get(item.id) === 0)
-  for (let index = 0; index < queue.length; index++) {
-    const item = queue[index]!
-    for (const to of outgoing.get(item.id)!) {
-      const downstream = itemById.get(to)!
-      downstream.rank = Math.max(downstream.rank, item.rank + 1)
-      incoming.set(to, incoming.get(to)! - 1)
-      if (incoming.get(to) === 0) queue.push(downstream)
-    }
-  }
+  const ranks = rankGraphComponents(
+    rankedItems.map((item) => item.id),
+    outgoing,
+  )
+  for (const item of rankedItems) item.rank = ranks.get(item.id)!
 
   const reversed = diagram.direction === "RL" || diagram.direction === "BT"
   const primaryStart = (item: (typeof items)[number]): number => {
@@ -600,28 +657,54 @@ function separateTopLevelItems(
     const size = horizontal ? item.bounds.width : item.bounds.height
     return reversed ? -(start + size) : start
   }
-  rankedItems.sort((a, b) => a.rank - b.rank || primaryStart(a) - primaryStart(b))
-
+  const itemsByRank = Map.groupBy(rankedItems, (item) => item.rank)
+  const rankKeys = [...itemsByRank.keys()].sort((a, b) => a - b)
   let cursor: number | undefined
-  for (const item of rankedItems) {
-    const start = primaryStart(item)
-    const size = horizontal ? item.bounds.width : item.bounds.height
+  let moved = false
+  for (const rank of rankKeys) {
+    const rankItems = itemsByRank.get(rank)!
+    const start = Math.min(...rankItems.map(primaryStart))
+    const end = Math.max(
+      ...rankItems.map((item) => primaryStart(item) + (horizontal ? item.bounds.width : item.bounds.height)),
+    )
     if (cursor === undefined) {
-      cursor = start + size + gap
+      cursor = end + gap
       continue
     }
     const shift = Math.max(0, cursor - start)
     if (shift > 0) {
-      for (const nodeId of item.nodeIds) {
-        const bounds = nodeBounds.get(nodeId)
-        if (bounds) {
-          const offset = reversed ? -shift : shift
-          translateBounds(bounds, horizontal ? offset : 0, horizontal ? 0 : offset)
-        }
+      moved = true
+      for (const item of rankItems) {
+        const offset = reversed ? -shift : shift
+        moveItem(item, horizontal ? offset : 0, horizontal ? 0 : offset)
+      }
+    }
+    cursor = end + shift + gap
+  }
+
+  for (const rank of rankKeys) {
+    const rankItems = itemsByRank
+      .get(rank)!
+      .toSorted((a, b) =>
+        horizontal ? a.bounds.top - b.bounds.top : a.bounds.left - b.bounds.left,
+      )
+    let crossCursor: number | undefined
+    for (const item of rankItems) {
+      const start = horizontal ? item.bounds.top : item.bounds.left
+      const size = horizontal ? item.bounds.height : item.bounds.width
+      if (crossCursor === undefined) {
+        crossCursor = start + size + gap
+        continue
+      }
+      const shift = Math.max(0, crossCursor - start)
+      if (shift > 0) {
+        moved = true
+        moveItem(item, horizontal ? 0 : shift, horizontal ? shift : 0)
       }
+      crossCursor = start + shift + size + gap
     }
-    cursor = start + shift + size + gap
   }
+  return moved
 }
 
 function layoutSubgraphs(
@@ -676,13 +759,27 @@ function layoutFlowchartWithDirection(
   const bounds = layoutRankedNodes(diagram, direction, sizes, minNodeGap, requestedMinRankGap)
   layoutLocalSubgraphDirections(diagram, bounds, sizes, minNodeGap, requestedMinRankGap)
 
-  let routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
-  let subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
-  separateTopLevelItems(diagram, bounds, subgraphBounds, Math.max(1, Math.floor(requestedMinRankGap / 2)))
-  routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
-  subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
-  routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
-  subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+  const subgraphs = diagram.subgraphs ?? []
+  let subgraphBounds = new Map<string, FlowchartSubgraphBounds>()
+  let routes: FlowchartEdgeRoute[]
+  if (subgraphs.length === 0) {
+    routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+  } else {
+    routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+    subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+    const moved = separateTopLevelItems(
+      diagram,
+      bounds,
+      subgraphBounds,
+      Math.max(1, Math.floor(requestedMinRankGap / 2)),
+    )
+    if (moved) {
+      routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge))
+      subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+    }
+    routes = routeFlowchartEdges(diagram, bounds, (edge) => edgeDirection(diagram, edge), subgraphBounds)
+    subgraphBounds = layoutSubgraphs(diagram, bounds, routes)
+  }
   const allBounds = [...bounds.values(), ...subgraphBounds.values(), ...routeRenderBounds(routes)]
   const dx = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.left)))
   const dy = Math.max(0, -Math.min(0, ...allBounds.map((bound) => bound.top)))