js/step-routing.mjs

// --- Step path routing using draw.io OrthConnector algorithm ---

let _cache = new Map()
let _cacheFrame = -1

export function clearStepCache() {
    _cache.clear()
    _cacheFrame = -1
}

export function calculateStepPoints(
    sourceX, sourceY, sourcePosition,
    targetX, targetY, targetPosition,
    offset, allNodes, nodeInternals, connFromId, connToId
) {
    // --- Per-frame cache ---
    let frame = Math.floor(Date.now() / 16)
    if (frame !== _cacheFrame) {
        _cache.clear(); _cacheFrame = frame
    }
    let ck = Math.round(sourceX / 10) + ',' + Math.round(sourceY / 10) + ',' +
        Math.round(targetX / 10) + ',' + Math.round(targetY / 10) + ',' +
        sourcePosition + ',' + targetPosition
    let cached = _cache.get(ck)
    if (cached) return cached

    // Collect obstacles (bbox for all visible nodes)
    let obs = []
    if (allNodes && allNodes.length > 0) {
        let ni = nodeInternals || {}
        for (let i = 0; i < allNodes.length; i++) {
            let n = allNodes[i]
            if (n.hidden) continue
            let w = (ni[n.id] && ni[n.id].width) || n.width || 100
            let h = (ni[n.id] && ni[n.id].height) || n.height || 40
            obs.push({ l: n.position.x, t: n.position.y, r: n.position.x + w, b: n.position.y + h })
        }
    }

    let sObs = findObstacleAt(obs, sourceX, sourceY)
    let tObs = findObstacleAt(obs, targetX, targetY)
    let hOff = offset || 24

    let result = lookupRoute(sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, sObs, tObs, hOff)
    if (!result) {
        result = segmentFallback(sourceX, sourceY, targetX, targetY, sObs, tObs)
    }

    if (_cache.size > 200) _cache.clear()
    _cache.set(ck, result)
    return result
}

// ==================== Helpers ====================

function findObstacleAt(obs, hx, hy) {
    for (let i = 0; i < obs.length; i++) {
        let o = obs[i]
        if (hx >= o.l - 5 && hx <= o.r + 5 && hy >= o.t - 5 && hy <= o.b + 5) return o
    }
    return null
}

// === draw.io OrthConnector algorithm (adapted from mxEdgeStyle.js) ===
// Route patterns encode movement steps as bit-packed integers.
// Each step: direction(0-3) | side_mask(5-8) | center(9) | source(10) | target(11)
// The quad rotation normalizes geometry so a 4×4 table covers all 16 direction combos.
// Constants match draw.io: WEST=1, NORTH=2, SOUTH=4, EAST=8

let _W = 1; let _N = 2; let _S = 4; let _E = 8
let _SIDE = 480; let _CTR = 512; let _SRC = 1024; let _TGT = 2048
let _dirV = [[-1, 0], [0, -1], [1, 0], [0, 1], [-1, 0], [0, -1], [1, 0]]

let _rp = [
    [[513, 2308, 2081, 2562], [513, 1090, 514, 2184, 2114, 2561], [513, 1090, 514, 2564, 2184, 2562], [513, 2308, 2561, 1090, 514, 2568, 2308]],
    [[514, 1057, 513, 2308, 2081, 2562], [514, 2184, 2114, 2561], [514, 2184, 2562, 1057, 513, 2564, 2184], [514, 1057, 513, 2568, 2308, 2561]],
    [[1090, 514, 1057, 513, 2308, 2081, 2562], [2114, 2561], [1090, 2562, 1057, 513, 2564, 2184], [1090, 514, 1057, 513, 2308, 2561, 2568]],
    [[2081, 2562], [1057, 513, 1090, 514, 2184, 2114, 2561], [1057, 513, 1090, 514, 2184, 2562, 2564], [1057, 2561, 1090, 514, 2568, 2308]]
]

function posToDirMask(pos) {
    switch (pos) {
    case 'left': return _W
    case 'top': return _N
    case 'right': return _E
    case 'bottom': return _S
    default: return _S
    }
}

/**
 * draw.io OrthConnector route generation.
 * Uses bit-encoded route patterns + quad rotation + limits clamping.
 * Returns waypoint array or null (fallback needed).
 */
function lookupRoute(sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, sObs, tObs, buf) {
    if (!sObs || !tObs) return null

    let sDir = posToDirMask(sourcePosition)
    let tDir = posToDirMask(targetPosition)

    // Node geometry [x, y, width, height]
    let geo = [
        [sObs.l, sObs.t, sObs.r - sObs.l, sObs.b - sObs.t],
        [tObs.l, tObs.t, tObs.r - tObs.l, tObs.b - tObs.t]
    ]

    // Too-close check: distance < 2*buf → fallback
    let ddx = sourceX - targetX; let ddy = sourceY - targetY
    let totalBuf = buf * 2
    if (ddx * ddx + ddy * ddy < totalBuf * totalBuf) return null

    // Limits: expanded bbox per node (prevents lines crossing nodes)
    // draw.io hardcoded indices: [1]=left, [2]=top, [4]=right, [8]=bottom
    let lim = [[], []]
    for (let i = 0; i < 2; i++) {
        lim[i][1] = geo[i][0] - buf
        lim[i][2] = geo[i][1] - buf
        lim[i][4] = geo[i][0] + geo[i][2] + buf
        lim[i][8] = geo[i][1] + geo[i][3] + buf
    }

    // Constraint: handle position relative to node (0–1)
    let con = [[0.5, 0.5], [0.5, 0.5]]
    let gw0 = geo[0][2] || 1; let gh0 = geo[0][3] || 1
    let gw1 = geo[1][2] || 1; let gh1 = geo[1][3] || 1
    con[0][0] = (sourceX - geo[0][0]) / gw0
    con[0][1] = (sourceY - geo[0][1]) / gh0
    con[1][0] = (targetX - geo[1][0]) / gw1
    con[1][1] = (targetY - geo[1][1]) / gh1

    // Quad: which quadrant is target center relative to source center
    //  0 | 1
    //  -----
    //  3 | 2
    let sCx = geo[0][0] + geo[0][2] / 2; let sCy = geo[0][1] + geo[0][3] / 2
    let tCx = geo[1][0] + geo[1][2] / 2; let tCy = geo[1][1] + geo[1][3] / 2
    let qx = sCx - tCx; let qy = sCy - tCy
    let quad = 0
    if (qx < 0) {
        quad = qy < 0 ? 2 : 1
    }
    else if (qy <= 0) {
        quad = qx === 0 ? 2 : 3
    }

    // Vertex separations (gap between nodes minus buffer, min 0)
    let vs = []
    vs[1] = Math.max(geo[0][0] - (geo[1][0] + geo[1][2]) - totalBuf, 0)
    vs[2] = Math.max(geo[0][1] - (geo[1][1] + geo[1][3]) - totalBuf, 0)
    vs[3] = Math.max(geo[1][0] - (geo[0][0] + geo[0][2]) - totalBuf, 0)
    vs[4] = Math.max(geo[1][1] - (geo[0][1] + geo[0][3]) - totalBuf, 0)

    // Route pattern lookup (with quad rotation)
    // draw.io: only map EAST(8)→3, others are already in range 1-4
    let si = sDir === _E ? 3 : sDir
    let ti = tDir === _E ? 3 : tDir
    si -= quad; ti -= quad
    if (si < 1) si += 4
    if (ti < 1) ti += 4
    let rp = _rp[si - 1][ti - 1]

    // Initial waypoint: handle position moved outward by buffer
    let wp = []
    for (let k = 0; k < 12; k++) wp[k] = [0, 0]
    wp[0][0] = geo[0][0]
    wp[0][1] = geo[0][1]
    switch (sDir) {
    case _W: wp[0][0] -= buf; wp[0][1] += con[0][1] * geo[0][3]; break
    case _S: wp[0][0] += con[0][0] * geo[0][2]; wp[0][1] += geo[0][3] + buf; break
    case _E: wp[0][0] += geo[0][2] + buf; wp[0][1] += con[0][1] * geo[0][3]; break
    case _N: wp[0][0] += con[0][0] * geo[0][2]; wp[0][1] -= buf; break
    }

    // Waypoint generation loop
    let ci = 0
    let lastOr = (sDir & (_E | _W)) > 0 ? 0 : 1
    let initOr = lastOr

    for (let i = 0; i < rp.length; i++) {
        let nd = rp[i] & 0xF
        // draw.io: only map EAST(8)→3
        let di = nd === _E ? 3 : nd
        di += quad
        if (di > 4) di -= 4
        let dir = _dirV[di - 1]
        let curOr = (di % 2 > 0) ? 0 : 1

        if (curOr !== lastOr) {
            ci++
            wp[ci] = [wp[ci - 1][0], wp[ci - 1][1]]
        }

        let tar = (rp[i] & _TGT) > 0
        let sou = (rp[i] & _SRC) > 0
        let side = (rp[i] & _SIDE) >> 5
        side = side << quad
        if (side > 0xF) side = side >> 4
        let ctr = (rp[i] & _CTR) > 0

        if ((sou || tar) && side < 9) {
            let st = sou ? 0 : 1
            let limit
            if (ctr && curOr === 0) {
                limit = geo[st][0] + con[st][0] * geo[st][2]
            }
            else if (ctr) {
                limit = geo[st][1] + con[st][1] * geo[st][3]
            }
            else {
                limit = lim[st][side]
            }
            if (curOr === 0) {
                let delta = (limit - wp[ci][0]) * dir[0]
                if (delta > 0) wp[ci][0] += dir[0] * delta
            }
            else {
                let delta = (limit - wp[ci][1]) * dir[1]
                if (delta > 0) wp[ci][1] += dir[1] * delta
            }
        }
        else if (ctr) {
            wp[ci][0] += dir[0] * Math.abs(vs[di] / 2)
            wp[ci][1] += dir[1] * Math.abs(vs[di] / 2)
        }

        // Collapse zero-length segments
        if (ci > 0 && wp[ci][curOr] === wp[ci - 1][curOr]) {
            ci--
        }
        else {
            lastOr = curOr
        }
    }

    // Build result: source handle → waypoints → target handle
    let pts = [{ x: sourceX, y: sourceY }]
    let tOr = (tDir & (_E | _W)) > 0 ? 0 : 1
    let sameOr = tOr === initOr ? 0 : 1

    for (let i = 0; i <= ci; i++) {
        if (i === ci && sameOr !== (ci + 1) % 2) break
        pts.push({ x: Math.round(wp[i][0] * 10) / 10, y: Math.round(wp[i][1] * 10) / 10 })
    }
    pts.push({ x: targetX, y: targetY })

    // Remove consecutive duplicates
    let result = [pts[0]]
    for (let i = 1; i < pts.length; i++) {
        if (Math.abs(pts[i].x - result[result.length - 1].x) > 0.5 ||
            Math.abs(pts[i].y - result[result.length - 1].y) > 0.5) {
            result.push(pts[i])
        }
    }
    return result
}

/**
 * SegmentConnector fallback (draw.io style).
 * Used when nodes are too close for OrthConnector (tooShort).
 */
function segmentFallback(sx, sy, tx, ty, sObs, tObs) {
    let pts = [{ x: sx, y: sy }]
    if (tObs && tx >= tObs.l && tx <= tObs.r && sy >= tObs.t && sy <= tObs.b) {
        if (!(sObs && sx >= sObs.l && sx <= sObs.r && ty >= sObs.t && ty <= sObs.b)) {
            pts.push({ x: sx, y: ty })
        }
    }
    else if (sx !== tx && sy !== ty) {
        pts.push({ x: tx, y: sy })
    }
    pts.push({ x: tx, y: ty })
    return pts
}