432 lines
19 KiB
JavaScript
432 lines
19 KiB
JavaScript
"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 lineagePathFromPoints(points) {
|
|
if (!points.length) return "";
|
|
return `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
|
|
}
|
|
|
|
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 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 points = [[aSideX, aMidY], [railX, aMidY], [railX, bMidY], [bSideX, bMidY]];
|
|
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: railX, unionY: Math.max(aMidY, bMidY) };
|
|
}
|
|
|
|
function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
|
const left = a.x <= b.x ? a : b;
|
|
const right = left === a ? b : a;
|
|
const midYLeft = left.y + nodeH / 2 + attachOffsetA;
|
|
const midYRight = right.y + nodeH / 2 + attachOffsetB;
|
|
const x1 = left.x + nodeW;
|
|
const x2 = right.x;
|
|
if (x2 <= x1) return { d: "", segments: [] };
|
|
const direct = left.rowIndex === right.rowIndex && x2 - x1 <= 156 && Math.abs(midYLeft - midYRight) < 10
|
|
&& (!Number.isFinite(unionY) || unionY <= Math.max(midYLeft, midYRight) + 16);
|
|
if (direct) {
|
|
const y = (midYLeft + midYRight) / 2;
|
|
return { d: `M ${x1.toFixed(1)} ${y.toFixed(1)} L ${x2.toFixed(1)} ${y.toFixed(1)}`, segments: [{ kind: "h", x1, y1: y, x2, y2: y }] };
|
|
}
|
|
const bend = 10;
|
|
const y = Number.isFinite(unionY) ? unionY : Math.max(midYLeft, midYRight);
|
|
const points = [
|
|
[x1, midYLeft], [x1 + bend, midYLeft], [x1 + bend, y], [x2 - bend, y], [x2 - bend, midYRight], [x2, midYRight]
|
|
];
|
|
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: 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);
|
|
}
|
|
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) {
|
|
const children = group.children;
|
|
if (!children.length) return { d: '' };
|
|
const joinX = group.joinX;
|
|
const fromY = group.fromY;
|
|
const laneY = group.busY;
|
|
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: lineageVerticalBridgePath(p.x, fromY, p.y, bridges) };
|
|
}
|
|
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 = lineageVerticalBridgePath(joinX, fromY, laneY, parentBridges);
|
|
const horizontal = ` L ${p.x.toFixed(1)} ${laneY.toFixed(1)}`;
|
|
const childLane = lineageVerticalBridgePath(p.x, laneY, p.y, childBridges).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 = lineageVerticalBridgePath(joinX, fromY, laneY, lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals));
|
|
d += ` M ${minX.toFixed(1)} ${laneY.toFixed(1)} L ${maxX.toFixed(1)} ${laneY.toFixed(1)}`;
|
|
for (const p of childPoints) {
|
|
const bridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals);
|
|
const branch = lineageVerticalBridgePath(p.x, laneY, p.y, bridges);
|
|
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", `<div class="lineage-stale-status">\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u306fOFF\u3067\u3059\u3002\u300c\u66f4\u65b0\u300d\u3092ON\u306b\u3059\u308b\u3068\u53cd\u6620\u3055\u308c\u307e\u3059\u3002</div>`);
|
|
}
|
|
|
|
function lineageAssignPartnerAttachOffsets(linkGroups) {
|
|
const buckets = new Map();
|
|
const pushBucket = (nodeId, side, group) => {
|
|
const key = `${nodeId}:${side}`;
|
|
if (!buckets.has(key)) buckets.set(key, []);
|
|
buckets.get(key).push(group);
|
|
};
|
|
for (const group of linkGroups) {
|
|
if ((group.parents || []).length < 2) continue;
|
|
const left = group.parents[0];
|
|
const right = group.parents[group.parents.length - 1];
|
|
if (!group.attachOffsets) group.attachOffsets = new Map();
|
|
pushBucket(left.node.id, 'right', group);
|
|
pushBucket(right.node.id, 'left', group);
|
|
}
|
|
for (const [key, groups] of buckets.entries()) {
|
|
groups.sort((a, b) => a.joinX - b.joinX || a.childTop - b.childTop);
|
|
const n = groups.length;
|
|
const step = 14;
|
|
groups.forEach((group, i) => {
|
|
const offset = n <= 1 ? 0 : (i - (n - 1) / 2) * step;
|
|
const [nodeId] = key.split(':');
|
|
group.attachOffsets.set(nodeId, offset);
|
|
});
|
|
}
|
|
}
|
|
|
|
function lineageBuildLinks(nodes, idSet, layout) {
|
|
const { positions, nodeW, nodeH } = layout;
|
|
const groups = new Map();
|
|
for (const child of nodes) {
|
|
const parents = lineageParentIds(child, idSet).filter(id => positions.has(id));
|
|
if (parents.length < 1 || !positions.has(child.id)) continue;
|
|
const key = parents.slice().sort().join('+');
|
|
if (!groups.has(key)) groups.set(key, { parentIds: parents.slice().sort(), childIds: [] });
|
|
groups.get(key).childIds.push(child.id);
|
|
}
|
|
|
|
const linkGroups = [];
|
|
for (const group of groups.values()) {
|
|
const parents = group.parentIds.map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x);
|
|
const children = group.childIds.map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x);
|
|
if (!parents.length || !children.length) continue;
|
|
const parentBottom = Math.max(...parents.map(p => p.y + nodeH));
|
|
const childTop = Math.min(...children.map(c => c.y));
|
|
const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length;
|
|
const directCouple = parents.length >= 2 && parents[0].rowIndex === parents[1].rowIndex && (parents[1].x - (parents[0].x + nodeW) <= 168);
|
|
linkGroups.push({ parents, children, parentBottom, childTop, joinX, directCouple, attachOffsets: new Map() });
|
|
}
|
|
lineageAssignPartnerAttachOffsets(linkGroups);
|
|
const childLinkEdges = linkGroups.reduce((sum, group) => sum + group.children.length * group.parents.length, 0);
|
|
|
|
const byGap = new Map();
|
|
for (const group of linkGroups) {
|
|
const key = `${group.parentBottom.toFixed(1)}:${group.childTop.toFixed(1)}`;
|
|
if (!byGap.has(key)) byGap.set(key, []);
|
|
byGap.get(key).push(group);
|
|
}
|
|
|
|
const partnerLinks = [];
|
|
const childLinks = [];
|
|
const horizontalSegments = [];
|
|
|
|
for (const groupsInGap of byGap.values()) {
|
|
groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length);
|
|
const top = Math.min(...groupsInGap.map(g => g.parentBottom)) + 58;
|
|
const bottom = Math.max(...groupsInGap.map(g => g.childTop)) - 54;
|
|
const span = Math.max(16, bottom - top);
|
|
groupsInGap.forEach((group, i) => {
|
|
const laneY = top + span * ((i + 1) / (groupsInGap.length + 1));
|
|
if (group.parents.length >= 2) {
|
|
const left = group.parents[0];
|
|
const right = group.parents[group.parents.length - 1];
|
|
// Marriage line stays at the parent-node center. The child trunk drops
|
|
// to a visible point below the parents before branching, so the fork is
|
|
// never hidden behind a node.
|
|
const attachA = 0;
|
|
const attachB = 0;
|
|
const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
|
|
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB, layout);
|
|
const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY;
|
|
const anchor = lineageChildAnchorOnMarriageLine(partner, Number.isFinite(partner.unionX) ? partner.unionX : group.joinX, unionY);
|
|
group.joinX = anchor.x;
|
|
group.fromY = anchor.y;
|
|
group.busY = group.children.length === 1 ? Math.min(group.childTop - 22, anchor.y + 38) : Math.min(group.childTop - 28, Math.max(anchor.y + 34, laneY + 24 + i * 5));
|
|
if (partner.d) {
|
|
partnerLinks.push(partner.d);
|
|
horizontalSegments.push(...partner.segments.filter(seg => seg.kind === 'h'));
|
|
}
|
|
} else {
|
|
group.fromY = group.parentBottom;
|
|
group.busY = group.children.length === 1 ? Math.min(group.childTop - 22, group.fromY + 38) : Math.min(group.childTop - 28, Math.max(group.fromY + 34, laneY + 24 + i * 5));
|
|
}
|
|
});
|
|
lineageSeparateChildBusLanes(groupsInGap, nodeW);
|
|
}
|
|
|
|
for (const groupsInGap of byGap.values()) {
|
|
groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length);
|
|
for (const group of groupsInGap) {
|
|
const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b);
|
|
if (group.children.length > 1 || (childCenters.length === 1 && Math.abs(childCenters[0] - group.joinX) > 0.5)) {
|
|
horizontalSegments.push({ kind: 'h', x1: Math.min(group.joinX, ...childCenters), y1: group.busY, x2: Math.max(group.joinX, ...childCenters), y2: group.busY });
|
|
}
|
|
const child = lineageBuildChildPathGroup(group, nodeW, nodeH, horizontalSegments);
|
|
if (child.d) childLinks.push(child.d);
|
|
}
|
|
}
|
|
|
|
return { partnerLinks, childLinks, childLinkGroups: linkGroups.length, childLinkEdges };
|
|
}
|