tarinai/js/ui_family_paths.js
2026-07-01 14:41:05 +09:00

377 lines
17 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 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 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 = 9;
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)) + 44;
const bottom = Math.max(...groupsInGap.map(g => g.childTop)) - 42;
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);
if (Number.isFinite(partner.unionX)) group.joinX = partner.unionX;
const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY;
const clearY = clamp(group.parentBottom + 22 + i * 2, unionY + 10, group.childTop - 36);
group.fromY = clearY;
group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, clearY + 28) : Math.min(group.childTop - 24, Math.max(clearY + 24, laneY + 18 + i * 3));
if (partner.d) {
partnerLinks.push(partner.d);
horizontalSegments.push(...partner.segments.filter(seg => seg.kind === 'h'));
}
const trunk = lineageVerticalBridgePath(group.joinX, unionY, group.fromY, []);
if (trunk) childLinks.push(trunk);
} else {
group.fromY = group.parentBottom;
group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, group.fromY + 30) : Math.min(group.childTop - 24, Math.max(group.fromY + 28, laneY + 18 + i * 3));
}
});
}
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 };
}