Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed Mermaid ASCII routing to stop unbounded pathfinder searches when an edge attachment point is unreachable. ([#5293](https://github.com/can1357/oh-my-pi/issues/5293))

## [16.4.6] - 2026-07-12

### Added
Expand Down
72 changes: 67 additions & 5 deletions packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,21 +108,75 @@ const MOVE_DIRS: GridCoord[] = [
{ x: 0, y: -1 },
]

/** Check if a grid cell is unoccupied and has non-negative coordinates. */
function isFreeInGrid(grid: Map<string, AsciiNode>, c: GridCoord): boolean {
if (c.x < 0 || c.y < 0) return false
interface SearchBounds {
minX: number
maxX: number
minY: number
maxY: number
expansionLimit: number
}

const MIN_ROUTING_MARGIN = 8
const MIN_EXPANSION_BUDGET = 256
const MAX_EXPANSION_BUDGET = 50_000

function searchBoundsFor(grid: Map<string, AsciiNode>, from: GridCoord, to: GridCoord): SearchBounds {
let minX = Math.min(from.x, to.x)
let maxX = Math.max(from.x, to.x)
let minY = Math.min(from.y, to.y)
let maxY = Math.max(from.y, to.y)

for (const key of grid.keys()) {
const comma = key.indexOf(',')
if (comma === -1) continue

const x = Number(key.slice(0, comma))
const y = Number(key.slice(comma + 1))
if (!Number.isFinite(x) || !Number.isFinite(y)) continue

minX = Math.min(minX, x)
maxX = Math.max(maxX, x)
minY = Math.min(minY, y)
maxY = Math.max(maxY, y)
}

const width = maxX - minX + 1
const height = maxY - minY + 1
const margin = Math.max(MIN_ROUTING_MARGIN, Math.ceil(Math.max(width, height) / 2))
const boundedMinX = Math.max(0, minX - margin)
const boundedMaxX = maxX + margin
const boundedMinY = Math.max(0, minY - margin)
const boundedMaxY = maxY + margin
const area = (boundedMaxX - boundedMinX + 1) * (boundedMaxY - boundedMinY + 1)

return {
minX: boundedMinX,
maxX: boundedMaxX,
minY: boundedMinY,
maxY: boundedMaxY,
expansionLimit: Math.min(MAX_EXPANSION_BUDGET, Math.max(MIN_EXPANSION_BUDGET, area * 4)),
}
}

/** Check if a grid cell is unoccupied and inside the bounded routing area. */
function isFreeInGrid(grid: Map<string, AsciiNode>, c: GridCoord, bounds: SearchBounds): boolean {
if (c.x < bounds.minX || c.x > bounds.maxX || c.y < bounds.minY || c.y > bounds.maxY) {
return false
}

return !grid.has(gridKey(c))
}

/**
* Find a path from `from` to `to` on the grid using A*.
* Returns the path as an array of GridCoords, or null if no path exists.
* Returns the path as an array of GridCoords, or null if no bounded path exists.
*/
export function getPath(
grid: Map<string, AsciiNode>,
from: GridCoord,
to: GridCoord,
): GridCoord[] | null {
const bounds = searchBoundsFor(grid, from, to)
const pq = new MinHeap()
pq.push({ coord: from, priority: 0 })

Expand All @@ -132,7 +186,13 @@ export function getPath(
const cameFrom = new Map<string, GridCoord | null>()
cameFrom.set(gridKey(from), null)

let expansions = 0

while (pq.length > 0) {
if (expansions++ >= bounds.expansionLimit) {
return null
}

const current = pq.pop()!.coord

if (gridCoordEquals(current, to)) {
Expand All @@ -150,9 +210,11 @@ export function getPath(

for (const dir of MOVE_DIRS) {
const next: GridCoord = { x: current.x + dir.x, y: current.y + dir.y }
const insideBounds = next.x >= bounds.minX && next.x <= bounds.maxX
&& next.y >= bounds.minY && next.y <= bounds.maxY

// Allow moving to the destination even if it's occupied (it's a node boundary)
if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) {
if (!insideBounds || (!isFreeInGrid(grid, next, bounds) && !gridCoordEquals(next, to))) {
continue
}

Expand Down
53 changes: 52 additions & 1 deletion packages/utils/test/mermaid-ascii.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from "bun:test";
import { renderMermaidAscii } from "../src/mermaid-ascii";
import { renderMermaidAscii, renderMermaidAsciiSafe } from "../src/mermaid-ascii";
import { getPath } from "../src/vendor/mermaid-ascii/ascii/pathfinder";
import { type AsciiNode, type GridCoord, gridKey } from "../src/vendor/mermaid-ascii/ascii/types";

describe("renderMermaidAscii", () => {
it("preserves an existing emoji edge label when a later narrow label collides with it", () => {
Expand All @@ -10,4 +12,53 @@ describe("renderMermaidAscii", () => {
expect(rendered).toContain("─🚀─");
expect(rendered).not.toContain("──A─");
});

it("returns a bounded fallback for declaration orders that make a clean route unreachable", () => {
const rendered = renderMermaidAsciiSafe(
[
"flowchart TD",
" Worker[Worker]",
" Archive[Archive]",
" Gateway[Gateway]",
" Audit[Audit]",
"",
" Worker --> Archive",
" Gateway --> Worker",
" Gateway --> Audit",
].join("\n"),
{ colorMode: "none" },
);

if (rendered === null) {
throw new Error("expected Mermaid ASCII renderer to return fallback output");
}

expect(rendered).toContain("Archive");
expect(rendered).toContain("Gateway");
expect(rendered).toContain("Audit");
});

it("returns null when the destination attachment point is enclosed", () => {
const node: AsciiNode = {
name: "blocker",
displayLabel: "blocker",
shape: "rectangle",
index: 0,
gridCoord: null,
drawingCoord: null,
drawing: null,
drawn: false,
styleClassName: "",
styleClass: { name: "", styles: {} },
};
const enclosed: GridCoord = { x: 2, y: 2 };
const blockers: GridCoord[] = [enclosed, { x: 1, y: 2 }, { x: 3, y: 2 }, { x: 2, y: 1 }, { x: 2, y: 3 }];
const grid = new Map<string, AsciiNode>();

for (const blocker of blockers) {
grid.set(gridKey(blocker), node);
}

expect(getPath(grid, { x: 0, y: 2 }, enclosed)).toBeNull();
});
});
Loading