"use strict"; function lineageSegmentKind(x1, y1, x2, y2) { if (Math.abs(y2 - y1) < 0.01) return 'h'; if (Math.abs(x2 - x1) < 0.01) return 'v'; return 'd'; } function lineageSegmentsFromPoints(points) { const segments = []; for (let i = 1; i < points.length; i++) { const [x1, y1] = points[i - 1]; const [x2, y2] = points[i]; segments.push({ kind: lineageSegmentKind(x1, y1, x2, y2), x1, y1, x2, y2 }); } return segments; } function lineageRoutePointKey(x, y) { return `${Math.round(Number(x || 0) * 10) / 10},${Math.round(Number(y || 0) * 10) / 10}`; } function lineageRouteSamePoint(a, b) { return Math.abs(Number(a?.[0]) - Number(b?.[0])) < 0.05 && Math.abs(Number(a?.[1]) - Number(b?.[1])) < 0.05; } function lineageCleanRoutePoints(points) { const raw = (points || []) .map(p => [Number(p?.[0]), Number(p?.[1])]) .filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1])); const deduped = []; for (const p of raw) { if (!deduped.length || !lineageRouteSamePoint(deduped[deduped.length - 1], p)) deduped.push(p); } let changed = true; while (changed) { changed = false; for (let i = 1; i < deduped.length - 1; i++) { const a = deduped[i - 1]; const b = deduped[i]; const c = deduped[i + 1]; const sameX = Math.abs(a[0] - b[0]) < 0.05 && Math.abs(b[0] - c[0]) < 0.05; const sameY = Math.abs(a[1] - b[1]) < 0.05 && Math.abs(b[1] - c[1]) < 0.05; const reverse = lineageRouteSamePoint(a, c); if (sameX || sameY || reverse) { deduped.splice(i, 1); changed = true; break; } } } return deduped; } function lineagePathFromPoints(points) { const clean = lineageForceOrthogonalPoints(points); if (!clean.length) return ""; return `M ${clean[0][0].toFixed(1)} ${clean[0][1].toFixed(1)}` + clean.slice(1).map(p => ` L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(''); } function lineagePointInsideRouteRect(x, y, rects = []) { return (rects || []).some(r => x > r.left && x < r.right && y > r.top && y < r.bottom); } function lineageRouteSegmentClear(x1, y1, x2, y2, rects = []) { if (!Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) return false; if (Math.abs(x1 - x2) < 0.05 && Math.abs(y1 - y2) < 0.05) return true; const vertical = Math.abs(x1 - x2) < 0.05; const horizontal = Math.abs(y1 - y2) < 0.05; if (!vertical && !horizontal) return false; for (const r of rects || []) { if (vertical) { const top = Math.min(y1, y2); const bottom = Math.max(y1, y2); if (x1 > r.left && x1 < r.right && Math.max(top, r.top) < Math.min(bottom, r.bottom)) return false; } else { const left = Math.min(x1, x2); const right = Math.max(x1, x2); if (y1 > r.top && y1 < r.bottom && Math.max(left, r.left) < Math.min(right, r.right)) return false; } } return true; } function lineageOrthogonalFallbackPoints(x1, y1, x2, y2, rects = []) { const candidates = [ [[x1, y1], [x2, y1], [x2, y2]], [[x1, y1], [x1, y2], [x2, y2]], ]; const score = points => { let bad = 0; let length = 0; for (let i = 1; i < points.length; i++) { const a = points[i - 1]; const b = points[i]; length += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); if (!lineageRouteSegmentClear(a[0], a[1], b[0], b[1], rects)) bad += 100000; } return bad + length; }; candidates.sort((a, b) => score(a) - score(b)); return lineageCleanRoutePoints(candidates[0]); } function lineageForceOrthogonalPoints(points) { const clean = lineageCleanRoutePoints(points); if (clean.length <= 1) return clean; const out = [clean[0]]; for (let i = 1; i < clean.length; i++) { const prev = out[out.length - 1]; const cur = clean[i]; const diagonal = Math.abs(prev[0] - cur[0]) >= 0.05 && Math.abs(prev[1] - cur[1]) >= 0.05; if (diagonal) { const via = [cur[0], prev[1]]; if (!lineageRouteSamePoint(prev, via)) out.push(via); } if (!lineageRouteSamePoint(out[out.length - 1], cur)) out.push(cur); } return lineageCleanRoutePoints(out); } function lineageRelevantRouteRects(x1, y1, x2, y2, rects = [], margin = 92) { const left = Math.min(x1, x2) - margin; const right = Math.max(x1, x2) + margin; const top = Math.min(y1, y2) - margin; const bottom = Math.max(y1, y2) + margin; return (rects || []).filter(r => r.right >= left && r.left <= right && r.bottom >= top && r.top <= bottom); } function lineageOrthogonalRoutePoints(x1, y1, x2, y2, rects = [], context = {}) { if (!Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) return []; if (Math.abs(x1 - x2) < 0.05 && Math.abs(y1 - y2) < 0.05) return [[x1, y1]]; const relevant = lineageRelevantRouteRects(x1, y1, x2, y2, rects, Number(context.routeMargin || 110) || 110); if (lineageRouteSegmentClear(x1, y1, x2, y2, relevant)) return [[x1, y1], [x2, y2]]; const width = Number(context.width || 0) || 0; const height = Number(context.height || 0) || 0; const minX = 8; const minY = Math.max(8, Number(context.minRouteY ?? 8) || 8); const maxX = width > 24 ? width - 8 : Math.max(x1, x2, ...relevant.map(r => r.right + 30), 240); const maxY = height > 24 ? height - 8 : Math.max(y1, y2, ...relevant.map(r => r.bottom + 30), 240); const xs = [x1, x2, minX, maxX]; const ys = [y1, y2, minY, maxY]; if (Number.isFinite(Number(context.preferredX))) xs.push(Number(context.preferredX)); if (Number.isFinite(Number(context.preferredY))) ys.push(Number(context.preferredY)); for (const r of relevant) { xs.push(r.left - 8, r.left, r.right, r.right + 8); ys.push(r.top - 8, r.top, r.bottom, r.bottom + 8); } const norm = values => Array.from(new Set(values .map(v => Math.round(Number(v) * 10) / 10) .filter(Number.isFinite))) .sort((a, b) => a - b); let ux = norm(xs).filter(v => v >= minX && v <= maxX); let uy = norm(ys).filter(v => v >= minY && v <= maxY); if (!ux.some(v => Math.abs(v - x1) < 0.05)) ux.push(x1); if (!ux.some(v => Math.abs(v - x2) < 0.05)) ux.push(x2); if (!uy.some(v => Math.abs(v - y1) < 0.05)) uy.push(y1); if (!uy.some(v => Math.abs(v - y2) < 0.05)) uy.push(y2); ux = Array.from(new Set(ux.map(v => Math.round(v * 10) / 10))).sort((a, b) => a - b); uy = Array.from(new Set(uy.map(v => Math.round(v * 10) / 10))).sort((a, b) => a - b); const passable = new Set(); const keyFor = (i, j) => `${i}:${j}`; for (let i = 0; i < ux.length; i++) { for (let j = 0; j < uy.length; j++) { if (!lineagePointInsideRouteRect(ux[i], uy[j], relevant)) passable.add(keyFor(i, j)); } } const ix1 = ux.findIndex(v => Math.abs(v - x1) < 0.05); const iy1 = uy.findIndex(v => Math.abs(v - y1) < 0.05); const ix2 = ux.findIndex(v => Math.abs(v - x2) < 0.05); const iy2 = uy.findIndex(v => Math.abs(v - y2) < 0.05); const start = keyFor(ix1, iy1); const goal = keyFor(ix2, iy2); passable.add(start); passable.add(goal); const dist = new Map([[start, 0]]); const prev = new Map(); const open = new Set([start]); const score = key => { const [i, j] = key.split(':').map(Number); return (dist.get(key) || 0) + Math.abs(ux[i] - x2) + Math.abs(uy[j] - y2) * 1.08; }; let guard = 0; while (open.size && guard++ < 20000) { let cur = null; for (const k of open) if (cur === null || score(k) < score(cur)) cur = k; if (cur === goal) break; open.delete(cur); const [i, j] = cur.split(':').map(Number); const neighbors = [[i - 1, j], [i + 1, j], [i, j - 1], [i, j + 1]]; for (const [ni, nj] of neighbors) { if (ni < 0 || nj < 0 || ni >= ux.length || nj >= uy.length) continue; const nk = keyFor(ni, nj); if (!passable.has(nk)) continue; if (!lineageRouteSegmentClear(ux[i], uy[j], ux[ni], uy[nj], relevant)) continue; const turnPenalty = (() => { const pk = prev.get(cur); if (!pk) return 0; const [pi, pj] = pk.split(':').map(Number); const wasH = pi !== i; const nowH = ni !== i; return wasH === nowH ? 0 : 5; })(); const step = Math.abs(ux[i] - ux[ni]) + Math.abs(uy[j] - uy[nj]) + turnPenalty; const nd = (dist.get(cur) || 0) + step; if (nd + 0.001 < (dist.get(nk) ?? Infinity)) { dist.set(nk, nd); prev.set(nk, cur); open.add(nk); } } } if (dist.has(goal)) { const keys = []; let cur = goal; while (cur) { keys.push(cur); if (cur === start) break; cur = prev.get(cur); } keys.reverse(); return lineageCleanRoutePoints(keys.map(k => { const [i, j] = k.split(':').map(Number); return [ux[i], uy[j]]; })); } // Last-resort route: prefer an L shape that is at least connected and then // simplify it. This should rarely be used, but avoids emitting orphaned SVG // fragments when the obstacle grid is over-constrained. const viaA = [[x1, y1], [x1, y2], [x2, y2]]; if (lineageRouteSegmentClear(x1, y1, x1, y2, relevant) && lineageRouteSegmentClear(x1, y2, x2, y2, relevant)) return lineageCleanRoutePoints(viaA); const viaB = [[x1, y1], [x2, y1], [x2, y2]]; if (lineageRouteSegmentClear(x1, y1, x2, y1, relevant) && lineageRouteSegmentClear(x2, y1, x2, y2, relevant)) return lineageCleanRoutePoints(viaB); return lineageOrthogonalFallbackPoints(x1, y1, x2, y2, relevant); } function lineageNodeRectsBetween(positions, nodeW, nodeH, y1, y2, excludeIds = new Set(), pad = 10) { const top = Math.min(y1, y2) - pad; const bottom = Math.max(y1, y2) + pad; const rects = []; for (const pos of positions?.values?.() || []) { const id = pos?.node?.id; if (!pos || (id && excludeIds.has(id))) continue; const rectTop = pos.y - pad; const rectBottom = pos.y + nodeH + pad; if (rectBottom < top || rectTop > bottom) continue; rects.push({ left: pos.x - pad, right: pos.x + nodeW + pad, top: rectTop, bottom: rectBottom }); } return rects; } function lineageVerticalLaneClear(x, y1, y2, rects) { const top = Math.min(y1, y2); const bottom = Math.max(y1, y2); return !(rects || []).some(r => x > r.left && x < r.right && Math.max(top, r.top) < Math.min(bottom, r.bottom)); } function lineageRouteNodeRects(positions, nodeW, nodeH, excludeIds = new Set(), pad = 8) { const rects = []; for (const pos of positions?.values?.() || []) { const id = pos?.node?.id; if (!pos || (id && excludeIds.has(id))) continue; rects.push({ left: pos.x - pad, right: pos.x + nodeW + pad, top: pos.y - pad, bottom: pos.y + nodeH + pad, }); } return rects; } function lineageHorizontalLaneClear(y, x1, x2, rects) { const left = Math.min(x1, x2); const right = Math.max(x1, x2); return !(rects || []).some(r => y > r.top && y < r.bottom && Math.max(left, r.left) < Math.min(right, r.right)); } function lineageRouteVerticalClear(x, y1, y2, rects) { const top = Math.min(y1, y2); const bottom = Math.max(y1, y2); return !(rects || []).some(r => x > r.left && x < r.right && Math.max(top, r.top) < Math.min(bottom, r.bottom)); } function lineageRouteHorizontalClear(y, x1, x2, rects) { const left = Math.min(x1, x2); const right = Math.max(x1, x2); return !(rects || []).some(r => y > r.top && y < r.bottom && Math.max(left, r.left) < Math.min(right, r.right)); } function lineageChooseVerticalDetourX(x, y1, y2, rects, context = {}) { const top = Math.min(y1, y2); const bottom = Math.max(y1, y2); const blockers = (rects || []).filter(r => Math.max(top, r.top) < Math.min(bottom, r.bottom)); const width = Number(context.width || 0) || 0; const minX = 8; const maxX = width > 16 ? width - 8 : Math.max(x + 120, ...blockers.map(r => r.right + 18), 240); const candidates = [x - 34, x + 34, x - 58, x + 58]; for (const r of blockers) { candidates.push(r.left - 12, r.right + 12, r.left - 22, r.right + 22); } const unique = Array.from(new Set(candidates .map(v => clamp(Math.round(v * 10) / 10, minX, maxX)))) .filter(v => Number.isFinite(v) && lineageRouteVerticalClear(v, y1, y2, rects)); const pool = unique.length ? unique : Array.from(new Set(candidates.map(v => clamp(Math.round(v * 10) / 10, minX, maxX)))).filter(Number.isFinite); if (!pool.length) return clamp(x, minX, maxX); return pool.sort((a, b) => Math.abs(a - x) - Math.abs(b - x))[0]; } function lineageChooseChildDetourY(y, x1, x2, rects, context = {}) { const height = Number(context.height || 0) || 0; const minY = 8; const maxY = height > 16 ? height - 8 : Math.max(y + 120, ...((rects || []).map(r => r.bottom + 18)), 240); const blockers = (rects || []).filter(r => Math.max(Math.min(x1, x2), r.left) < Math.min(Math.max(x1, x2), r.right)); const candidates = [y - 18, y + 18, y - 32, y + 32, y - 52, y + 52]; for (const r of blockers) { candidates.push(r.top - 12, r.bottom + 12, r.top - 22, r.bottom + 22); } const unique = Array.from(new Set(candidates .map(v => clamp(Math.round(v * 10) / 10, minY, maxY)))) .filter(v => Number.isFinite(v) && lineageRouteHorizontalClear(v, x1, x2, rects)); const pool = unique.length ? unique : Array.from(new Set(candidates.map(v => clamp(Math.round(v * 10) / 10, minY, maxY)))).filter(Number.isFinite); if (!pool.length) return clamp(y, minY, maxY); return pool.sort((a, b) => Math.abs(a - y) - Math.abs(b - y))[0]; } function lineageVerticalPathAvoidingNodes(x, y1, y2, bridges = [], rects = [], context = {}) { if (lineageRouteVerticalClear(x, y1, y2, rects)) return lineageVerticalBridgePath(x, y1, y2, bridges); return lineagePathFromPoints(lineageOrthogonalRoutePoints(x, y1, x, y2, rects, context)); } function lineageHorizontalConnectorAvoidingNodes(x1, y, x2, rects = [], context = {}) { if (Math.abs(x2 - x1) <= 0.5) return ''; if (lineageRouteHorizontalClear(y, x1, x2, rects)) return ` L ${x2.toFixed(1)} ${y.toFixed(1)}`; const points = lineageOrthogonalRoutePoints(x1, y, x2, y, rects, context).slice(1); return points.map(p => ` L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(''); } function lineageHorizontalPathAvoidingNodes(x1, y, x2, rects = [], context = {}) { return lineagePathFromPoints(lineageOrthogonalRoutePoints(x1, y, x2, y, rects, context)); } function lineageLanePenalty(y, x1, x2, rects) { let penalty = 0; for (const r of rects || []) { const overlap = Math.max(0, Math.min(Math.max(x1, x2), r.right) - Math.max(Math.min(x1, x2), r.left)); if (overlap <= 0) continue; if (y > r.top && y < r.bottom) penalty += 100000 + overlap; else penalty += Math.max(0, 26 - Math.min(Math.abs(y - r.top), Math.abs(y - r.bottom))) * 3; } return penalty; } function lineageChooseHorizontalDetourY(y, x1, x2, a, b, nodeW, nodeH, rects, preferredY = NaN, context = {}) { const rowTop = Math.min(a.y, b.y); const rowBottom = Math.max(a.y, b.y) + nodeH; const minY = Math.max(8, Number(context.minRouteY ?? 10) || 10); const maxY = Number.isFinite(Number(context.height)) ? Math.max(minY, Number(context.height) - 8) : Number.POSITIVE_INFINITY; const candidates = []; if (Number.isFinite(preferredY)) candidates.push(preferredY); // Escaping above the first generation is allowed, but keep the route inside // the visible SVG instead of letting it disappear above the canvas. candidates.push(rowTop - 48, rowTop - 32, rowTop - 18, rowBottom + 18, rowBottom + 32, rowBottom + 48); const unique = Array.from(new Set(candidates .map(v => clamp(Math.round(v * 10) / 10, minY, maxY)))) .filter(Number.isFinite); unique.sort((ya, yb) => { const ca = lineageLanePenalty(ya, x1, x2, rects) + Math.abs(ya - y) * 0.08; const cb = lineageLanePenalty(yb, x1, x2, rects) + Math.abs(yb - y) * 0.08; return ca - cb; }); return unique[0] ?? clamp(y, minY, maxY); } function lineageAutoSideOffsetForLane(laneY, rowTop, rowBottom, nodeH) { if (!Number.isFinite(laneY)) return 0; if (laneY < rowTop - 2) return -nodeH * 0.30; if (laneY > rowBottom + 2) return nodeH * 0.30; return 0; } function lineageCrossGenerationPartnerLane(a, b, nodeW, nodeH, context = {}) { const ay = a.y + nodeH / 2; const by = b.y + nodeH / 2; const exclude = new Set([a.node?.id, b.node?.id].filter(Boolean)); const rects = lineageNodeRectsBetween(context.positions, nodeW, nodeH, ay, by, exclude, 12); const ax = a.x + nodeW / 2; const bx = b.x + nodeW / 2; const directX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2; if (lineageVerticalLaneClear(directX, ay, by, rects)) return directX; const minLeft = Math.min(a.x, b.x, ...rects.map(r => r.left)); const maxRight = Math.max(a.x + nodeW, b.x + nodeW, ...rects.map(r => r.right)); const width = Number(context.width || 0) || Infinity; const candidates = [ maxRight + 18, minLeft - 18, Math.max(a.x + nodeW, b.x + nodeW) + 18, Math.min(a.x, b.x) - 18, ].filter(x => Number.isFinite(x) && x >= 12 && x <= width - 12); const clear = candidates.filter(x => lineageVerticalLaneClear(x, ay, by, rects)); const pool = clear.length ? clear : candidates; if (pool.length) return pool.sort((x, y) => Math.abs(x - directX) - Math.abs(y - directX))[0]; return directX; } function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context = {}) { const railX = lineageCrossGenerationPartnerLane(a, b, nodeW, nodeH, context); const aMidY = a.y + nodeH / 2; const bMidY = b.y + nodeH / 2; const aSideX = railX >= a.x + nodeW / 2 ? a.x + nodeW : a.x; const bSideX = railX >= b.x + nodeW / 2 ? b.x + nodeW : b.x; const exclude = new Set([a.node?.id, b.node?.id].filter(Boolean)); const rects = lineageRouteNodeRects(context.positions, nodeW, nodeH, exclude, 10); const points = lineageOrthogonalRoutePoints(aSideX, aMidY, bSideX, bMidY, rects, { ...context, preferredX: railX, routeMargin: 180 }); const clean = lineageCleanRoutePoints(points); const segments = lineageSegmentsFromPoints(clean); const verticals = segments.filter(seg => seg.kind === "v").sort((sa, sb) => Math.abs(sb.y2 - sb.y1) - Math.abs(sa.y2 - sa.y1)); const rail = verticals[0]; return { d: lineagePathFromPoints(clean), segments, unionX: rail ? rail.x1 : railX, unionY: Math.max(aMidY, bMidY) }; } function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0, context = {}) { const left = a.x <= b.x ? a : b; const right = left === a ? b : a; let leftOffset = left === a ? attachOffsetA : attachOffsetB; let rightOffset = right === a ? attachOffsetA : attachOffsetB; const x1 = left.x + nodeW; const x2 = right.x; if (x2 <= x1) return { d: "", segments: [] }; const exclude = new Set([left.node?.id, right.node?.id].filter(Boolean)); const rects = lineageRouteNodeRects(context.positions, nodeW, nodeH, exclude, 10); let midYLeft = left.y + nodeH / 2 + leftOffset; let midYRight = right.y + nodeH / 2 + rightOffset; const directY = (midYLeft + midYRight) / 2; const directClear = lineageHorizontalLaneClear(directY, x1, x2, rects); const direct = directClear && left.rowIndex === right.rowIndex && x2 - x1 <= 156 && Math.abs(midYLeft - midYRight) < 10 && (!Number.isFinite(unionY) || unionY <= Math.max(midYLeft, midYRight) + 16); if (direct) { const points = Math.abs(midYLeft - midYRight) < 0.5 ? [[x1, midYLeft], [x2, midYRight]] : [[x1, midYLeft], [(x1 + x2) / 2, midYLeft], [(x1 + x2) / 2, midYRight], [x2, midYRight]]; return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(lineageCleanRoutePoints(points)), unionX: (x1 + x2) / 2, unionY: (midYLeft + midYRight) / 2 }; } const gap = x2 - x1; const bend = Math.max(10, Math.min(24, gap * 0.20)); const y = directClear && Number.isFinite(unionY) && lineageHorizontalLaneClear(unionY, x1, x2, rects) ? clamp(unionY, 10, Number.isFinite(Number(context.height)) ? Number(context.height) - 8 : Number.POSITIVE_INFINITY) : lineageChooseHorizontalDetourY(directY, x1, x2, left, right, nodeW, nodeH, rects, unionY, context); // If the route escapes above/below the row, attach from the corresponding // side portion of the node instead of forcing every edge out of side-center. const autoOffset = lineageAutoSideOffsetForLane(y, Math.min(left.y, right.y), Math.max(left.y, right.y) + nodeH, nodeH); if (Math.abs(autoOffset) > 0.1) { const minOffset = -nodeH * 0.38; const maxOffset = nodeH * 0.38; leftOffset = clamp(leftOffset + autoOffset, minOffset, maxOffset); rightOffset = clamp(rightOffset + autoOffset, minOffset, maxOffset); midYLeft = left.y + nodeH / 2 + leftOffset; midYRight = right.y + nodeH / 2 + rightOffset; } const routeContext = { ...context, preferredY: y, routeMargin: Math.max(140, Math.abs(x2 - x1) + nodeW) }; const points = lineageOrthogonalRoutePoints(x1, midYLeft, x2, midYRight, rects, routeContext); const clean = lineageCleanRoutePoints(points); const horizontals = lineageSegmentsFromPoints(clean).filter(seg => seg.kind === "h"); const mainH = horizontals.sort((sa, sb) => Math.abs(sb.x2 - sb.x1) - Math.abs(sa.x2 - sa.x1))[0]; return { d: lineagePathFromPoints(clean), segments: lineageSegmentsFromPoints(clean), unionX: mainH ? (mainH.x1 + mainH.x2) / 2 : (x1 + x2) / 2, unionY: mainH ? mainH.y1 : y }; } function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0, context = {}) { if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context); return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB, context); } function lineageChildAnchorOnMarriageLine(partner, fallbackX, fallbackY) { const horizontals = (partner?.segments || []) .filter(seg => seg.kind === "h" && Math.abs(seg.x2 - seg.x1) > 10) .sort((a, b) => { const ty = Number.isFinite(partner?.unionY) ? partner.unionY : fallbackY; const dy = Math.abs(a.y1 - ty) - Math.abs(b.y1 - ty); if (Math.abs(dy) > 0.01) return dy; return Math.abs(b.x2 - b.x1) - Math.abs(a.x2 - a.x1); }); const seg = horizontals[0] || null; if (!seg) return { x: fallbackX, y: fallbackY }; const minX = Math.min(seg.x1, seg.x2) + 8; const maxX = Math.max(seg.x1, seg.x2) - 8; const midX = (seg.x1 + seg.x2) / 2; const x = minX <= maxX ? clamp(Number.isFinite(fallbackX) ? fallbackX : midX, minX, maxX) : midX; return { x, y: seg.y1 }; } function lineageVerticalBridgePath(x, y1, y2, bridges = [], radius = 8.5) { if (!Number.isFinite(x) || !Number.isFinite(y1) || !Number.isFinite(y2)) return ''; const down = y2 >= y1; const sign = down ? 1 : -1; const sorted = bridges.slice().sort((a, b) => down ? a - b : b - a).filter(y => Math.abs(y - y1) > radius + 1 && Math.abs(y - y2) > radius + 1); let d = `M ${x.toFixed(1)} ${y1.toFixed(1)}`; let cursor = y1; for (const by of sorted) { const before = by - sign * radius; const after = by + sign * radius; d += ` L ${x.toFixed(1)} ${before.toFixed(1)}`; d += ` Q ${(x + radius * 1.55).toFixed(1)} ${by.toFixed(1)} ${x.toFixed(1)} ${after.toFixed(1)}`; cursor = after; } if (Math.abs(cursor - y2) > 0.01) d += ` L ${x.toFixed(1)} ${y2.toFixed(1)}`; return d; } function lineageIntersectionYsForVertical(x, y1, y2, horizontalSegments) { const top = Math.min(y1, y2); const bottom = Math.max(y1, y2); const ys = []; for (const seg of horizontalSegments || []) { if (seg.kind !== 'h') continue; const minX = Math.min(seg.x1, seg.x2); const maxX = Math.max(seg.x1, seg.x2); const y = seg.y1; if (x > minX + 3 && x < maxX - 3 && y > top + 3 && y < bottom - 3) ys.push(y); } return ys; } function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals, context = {}) { const children = group.children; if (!children.length) return { d: '' }; const joinX = group.joinX; const fromY = group.fromY; const laneY = group.busY; const routeRects = group.routeRects || []; const childPoints = children .map(c => ({ x: c.x + nodeW / 2, y: c.y })) .sort((a, b) => a.x - b.x || a.y - b.y); if (childPoints.length === 1 && Math.abs(childPoints[0].x - joinX) <= 0.5) { const p = childPoints[0]; const bridges = lineageIntersectionYsForVertical(p.x, fromY, p.y, bridgeHorizontals); return { d: lineageVerticalPathAvoidingNodes(p.x, fromY, p.y, bridges, routeRects, context) }; } if (childPoints.length === 1) { const p = childPoints[0]; const parentBridges = lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals); const childBridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals); const parentLane = lineageVerticalPathAvoidingNodes(joinX, fromY, laneY, parentBridges, routeRects, context); const horizontal = lineageHorizontalConnectorAvoidingNodes(joinX, laneY, p.x, routeRects, context); const childLane = lineageVerticalPathAvoidingNodes(p.x, laneY, p.y, childBridges, routeRects, context).replace(/^M\s+[-\d.]+\s+[-\d.]+/, ""); return { d: `${parentLane}${horizontal}${childLane}` }; } const minX = Math.min(joinX, ...childPoints.map(p => p.x)); const maxX = Math.max(joinX, ...childPoints.map(p => p.x)); let d = lineageVerticalPathAvoidingNodes(joinX, fromY, laneY, lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals), routeRects, context); d += ` ${lineageHorizontalPathAvoidingNodes(minX, laneY, maxX, routeRects, context)}`; for (const p of childPoints) { const bridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals); const branch = lineageVerticalPathAvoidingNodes(p.x, laneY, p.y, bridges, routeRects, context); d += ` ${branch}`; } return { d }; } function lineageChildBusSpan(group, nodeW) { const childCenters = (group.children || []).map(c => c.x + nodeW / 2); if (!childCenters.length) return null; return { left: Math.min(group.joinX, ...childCenters), right: Math.max(group.joinX, ...childCenters), }; } function lineageSpansOverlap(a, b, pad = 12) { if (!a || !b) return false; return Math.max(a.left, b.left) <= Math.min(a.right, b.right) + pad; } function lineageSeparateChildBusLanes(groupsInGap, nodeW) { const lanes = []; const ordered = (groupsInGap || []) .filter(group => group && Number.isFinite(group.busY)) .map(group => ({ group, span: lineageChildBusSpan(group, nodeW), baseY: group.busY })) .filter(entry => entry.span) .sort((a, b) => a.baseY - b.baseY || a.span.left - b.span.left || a.span.right - b.span.right); for (const entry of ordered) { let laneIndex = 0; while (lanes[laneIndex]?.some(prev => Math.abs(prev.group.busY - entry.baseY) < 12 && lineageSpansOverlap(prev.span, entry.span))) { laneIndex += 1; } if (!lanes[laneIndex]) lanes[laneIndex] = []; const fromY = Number(entry.group.fromY); const childTop = Number(entry.group.childTop); const minY = Number.isFinite(fromY) ? fromY + 26 : -Infinity; const maxY = Number.isFinite(childTop) ? childTop - 18 : Infinity; const offset = laneIndex * 9; entry.group.busY = clamp(entry.baseY - offset, minY, maxY); lanes[laneIndex].push(entry); } } function lineageComponentSignature(component, family) { family = lineageEdgeFamily(family); const idSet = new Set(component.map(x => x?.id).filter(Boolean)); return component .filter(Boolean) .map(n => { const parents = lineageMutualParentIds(n, family, idSet).join(","); const children = lineageMutualChildIds(n, family, idSet).join(","); return [ n.id, n.name || "", n.type || "", n.generation || 1, n.birthTime || 0, Number(n.scale || 0).toFixed(3), Number(n.adultScale || 0).toFixed(3), Number(n.growth || 0).toFixed(2), n.hasPaired ? 1 : 0, n.alive === false ? 0 : 1, n.deathReason || "", parents, children, ].join(":"); }) .sort() .join("|"); } function lineageComponentIdentity(component) { return Array.from(new Set((component || []).map(n => n?.id).filter(Boolean))).sort().join(","); } function lineageFamilyCacheKey(component, family, signature = "") { const uniqueComponent = Array.from(new Map((component || []).filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort); return `${lineageComponentIdentity(uniqueComponent)}:${signature || lineageComponentSignature(uniqueComponent, family)}`; } function lineageHydrateFamilyHtml(html, index) { return html .split("{{LINEAGE_INDEX}}").join(String(index + 1)) .split("{{LINEAGE_MASK_ID}}").join(`lineage-node-mask-${index}`); } function lineageCaptureArchiveScroll() { const root = ui.archiveContent; if (!root) return null; const familyScroll = {}; for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { const key = el.getAttribute("data-lineage-key"); if (key) familyScroll[key] = el.scrollLeft || 0; } return { top: root.scrollTop || 0, left: root.scrollLeft || 0, familyScroll }; } function lineageRestoreArchiveScroll(state) { const root = ui.archiveContent; if (!root || !state) return; const maxTop = Math.max(0, root.scrollHeight - root.clientHeight); const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth); root.scrollTop = Math.min(state.top || 0, maxTop); root.scrollLeft = Math.min(state.left || 0, maxLeft); for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { const key = el.getAttribute("data-lineage-key"); if (!key || !Object.prototype.hasOwnProperty.call(state.familyScroll || {}, key)) continue; el.scrollLeft = Math.min(state.familyScroll[key] || 0, Math.max(0, el.scrollWidth - el.clientWidth)); } } function lineageExpectedChildEdgeCount(component, family) { family = lineageEdgeFamily(family); const idSet = new Set(component.map(x => x?.id).filter(Boolean)); let count = 0; for (const n of component || []) count += lineageParentIds(n, idSet, family).length; return count; } function lineageArchiveNearViewport() { if (!ui.archiveContent?.getBoundingClientRect) return true; const rect = ui.archiveContent.getBoundingClientRect(); const h = window.innerHeight || document.documentElement?.clientHeight || 0; return rect.top < h + 260 && rect.bottom > -260; } function lineageSetArchiveStaleStatus(show) { if (!ui.archiveContent) return; const existing = ui.archiveContent.querySelector(".lineage-stale-status"); if (!show) { existing?.remove(); return; } if (existing) return; ui.archiveContent.insertAdjacentHTML("afterbegin", `