This commit is contained in:
33333-33333 2026-07-03 20:57:02 +09:00
commit 88f618cadf
55 changed files with 1299 additions and 447 deletions

View file

@ -42,6 +42,66 @@ function lineageVerticalLaneClear(x, y1, y2, rects) {
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 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;
@ -76,31 +136,53 @@ function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY, context =
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) {
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;
const midYLeft = left.y + nodeH / 2 + attachOffsetA;
const midYRight = right.y + nodeH / 2 + attachOffsetB;
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 direct = left.rowIndex === right.rowIndex && x2 - x1 <= 156 && Math.abs(midYLeft - midYRight) < 10
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 y = (midYLeft + midYRight) / 2;
const y = directY;
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 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 xLeftBend = Math.min(x1 + bend, x2 - 4);
const xRightBend = Math.max(x2 - bend, x1 + 4);
const points = [
[x1, midYLeft], [x1 + bend, midYLeft], [x1 + bend, y], [x2 - bend, y], [x2 - bend, midYRight], [x2, midYRight]
[x1, midYLeft], [xLeftBend, midYLeft], [xLeftBend, y], [xRightBend, y], [xRightBend, midYRight], [x2, midYRight]
];
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y };
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (xLeftBend + xRightBend) / 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);
return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB, context);
}
function lineageChildAnchorOnMarriageLine(partner, fallbackX, fallbackY) {
const horizontals = (partner?.segments || [])
@ -395,8 +477,8 @@ function lineageBuildLinks(nodes, idSet, layout) {
// 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 attachA = group.attachOffsets?.get?.(left.node?.id) || 0;
const attachB = group.attachOffsets?.get?.(right.node?.id) || 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;